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 | ||
|
|
77a752efe3 | ||
|
|
781fbfaa30 | ||
|
|
61c11c0fe3 | ||
|
|
64b746f007 | ||
|
|
9cc72c7575 | ||
|
|
9e4f43f648 | ||
|
|
5f044b959e | ||
|
|
68a49be8c1 | ||
|
|
8eb82d6cdb | ||
|
|
d327db3f57 | ||
|
|
59e6570f17 | ||
|
|
82a2c6cb7f | ||
|
|
24eaf597fd | ||
|
|
14a22ddb66 | ||
|
|
9156acea5f | ||
|
|
5d0906f00e | ||
|
|
901e967b53 | ||
|
|
4ab4f70349 | ||
|
|
64e80986ea | ||
|
|
816c6ffcf1 | ||
|
|
2166d1aa4b | ||
|
|
0a9a09bec2 | ||
|
|
34ec91684e | ||
|
|
11f1e332f7 | ||
|
|
bd9e091e65 | ||
|
|
d38c783dcc | ||
|
|
c825caa7a8 | ||
|
|
215652570c | ||
|
|
79552bfae1 | ||
|
|
8fc04563e1 | ||
|
|
19993bafc1 | ||
|
|
da1793a902 | ||
|
|
14c87f7fa9 | ||
|
|
9d4ccb9254 | ||
|
|
d30b305ff2 | ||
|
|
5abe4bd0c3 | ||
|
|
3ed9f29d9a | ||
|
|
04eaa91bd8 | ||
|
|
443698b507 | ||
|
|
db2908b3ef | ||
|
|
0838c2ec89 | ||
|
|
80c5fdc095 | ||
|
|
a1be0dfe68 | ||
|
|
ac4039393d | ||
|
|
bb53085c21 | ||
|
|
031dfa8f46 | ||
|
|
6322a425d8 | ||
|
|
9f08df1c39 | ||
|
|
aeeb658269 | ||
|
|
8e088576c7 | ||
|
|
eb2ff8ed59 | ||
|
|
dd3b51a2ae | ||
|
|
1a155e3627 | ||
|
|
cd13921322 | ||
|
|
46e3619a38 | ||
|
|
dcf006905c | ||
|
|
7cf2dfeaf9 | ||
|
|
69229964f4 | ||
|
|
09848adddc | ||
|
|
f853dd479e | ||
|
|
829c236d6c | ||
|
|
c9fd1379e1 | ||
|
|
28b6f04ea4 | ||
|
|
e6744c1d0a | ||
|
|
5152366717 | ||
|
|
e8691a43ba | ||
|
|
b83a4f4455 | ||
|
|
9d7091b1b8 | ||
|
|
f650183936 | ||
|
|
33a6342a07 | ||
|
|
75548812d0 | ||
|
|
5b96f53930 | ||
|
|
d354709939 | ||
|
|
3cd80ead81 | ||
|
|
9e2ffdb758 | ||
|
|
9718b8a78f | ||
|
|
ee844564de | ||
|
|
04b6431726 | ||
|
|
0c6f8e2d68 | ||
|
|
4fe0405432 | ||
|
|
1f0f75baf8 | ||
|
|
7f95a71426 | ||
|
|
08f4b61523 | ||
|
|
f5ffe81c72 | ||
|
|
656e238a59 | ||
|
|
c170d6091e | ||
|
|
ae60d58893 | ||
|
|
68982e9a85 | ||
|
|
eb9e2db41a | ||
|
|
b59c6856bd | ||
|
|
a00817b93e | ||
|
|
7398261c50 | ||
|
|
73f3ec51f7 | ||
|
|
6c39204301 | ||
|
|
0c3089344b | ||
|
|
6ab606fa54 | ||
|
|
96390110f8 | ||
|
|
6f2f9236b0 | ||
|
|
efb61107fe | ||
|
|
16e780c2df | ||
|
|
f3bf0b2f5c | ||
|
|
1f74e4d234 | ||
|
|
19c2de5f61 | ||
|
|
5ae2bad549 | ||
|
|
e487aa78f3 | ||
|
|
b9079fe4e2 | ||
|
|
206a6bff09 | ||
|
|
6ee31ae45d | ||
|
|
521f8266cf | ||
|
|
1f5e5759cc |
@@ -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).
|
||||||
|
|
||||||
|
|||||||
+251
@@ -0,0 +1,251 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// ADIF monitor: watches a configurable list of external ADIF files (fldigi's
|
||||||
|
// RTTY logbook, N1MM, VarAC…) and imports newly appended QSOs into OpsLog as if
|
||||||
|
// they had been logged here — same enrichment, dedup and automatic upload to
|
||||||
|
// external services (QRZ, Club Log…). Deliberately simpler than Log4OM's monitor:
|
||||||
|
// no per-file "upload" / "delete after load" toggles — importing + auto-upload is
|
||||||
|
// just what happens.
|
||||||
|
//
|
||||||
|
// Design notes:
|
||||||
|
// - A newly added file starts at its CURRENT size (Offset = -1 sentinel → set to
|
||||||
|
// size on first scan) so the QSOs already in it are NOT bulk-imported; only
|
||||||
|
// contacts appended AFTER you add the file come in.
|
||||||
|
// - Reads only up to the last complete <eor>, so a half-written record waits for
|
||||||
|
// the next poll instead of importing a truncated QSO.
|
||||||
|
// - Each record is fed through LogUDPLoggedADIF, which already does the full
|
||||||
|
// enrichment + ±2-minute dedup (shared udpLogMu, so it can't race the UDP
|
||||||
|
// auto-log) + automatic external-service upload.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyADIFMonitor = "adifmon.config"
|
||||||
|
|
||||||
|
// ADIFWatchFile is one monitored ADIF file.
|
||||||
|
type ADIFWatchFile struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// Offset is the number of bytes already consumed. -1 means "not yet
|
||||||
|
// initialised": the first scan sets it to the file's current size so existing
|
||||||
|
// history is skipped.
|
||||||
|
Offset int64 `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADIFMonitorConfig is the whole monitor setup: a master switch + the file list.
|
||||||
|
type ADIFMonitorConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Files []ADIFWatchFile `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const eorTag = "<eor>"
|
||||||
|
|
||||||
|
func (a *App) loadADIFMonitorLocked() ADIFMonitorConfig {
|
||||||
|
var cfg ADIFMonitorConfig
|
||||||
|
if a.settings == nil {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
s, _ := a.settings.GetGlobal(a.ctx, keyADIFMonitor)
|
||||||
|
if strings.TrimSpace(s) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(s), &cfg)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) saveADIFMonitorLocked(cfg ADIFMonitorConfig) {
|
||||||
|
b, _ := json.Marshal(cfg)
|
||||||
|
a.setSettingGlobal(keyADIFMonitor, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetADIFMonitor returns the monitor configuration for the settings UI.
|
||||||
|
func (a *App) GetADIFMonitor() ADIFMonitorConfig {
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
defer a.adifMonMu.Unlock()
|
||||||
|
return a.loadADIFMonitorLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveADIFMonitor persists the monitor configuration. A file the user just added
|
||||||
|
// gets Offset = -1 so its existing content is skipped (only QSOs logged AFTER it
|
||||||
|
// was added import); a file already present keeps its current read position.
|
||||||
|
func (a *App) SaveADIFMonitor(cfg ADIFMonitorConfig) error {
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
defer a.adifMonMu.Unlock()
|
||||||
|
old := a.loadADIFMonitorLocked()
|
||||||
|
oldOff := make(map[string]int64, len(old.Files))
|
||||||
|
for _, f := range old.Files {
|
||||||
|
oldOff[strings.TrimSpace(f.Path)] = f.Offset
|
||||||
|
}
|
||||||
|
for i := range cfg.Files {
|
||||||
|
cfg.Files[i].Path = strings.TrimSpace(cfg.Files[i].Path)
|
||||||
|
if off, ok := oldOff[cfg.Files[i].Path]; ok {
|
||||||
|
cfg.Files[i].Offset = off // keep the read position of an existing file
|
||||||
|
} else {
|
||||||
|
cfg.Files[i].Offset = -1 // new file → skip its existing history
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.saveADIFMonitorLocked(cfg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickADIFMonitorFile opens a file dialog to choose an ADIF file to monitor.
|
||||||
|
func (a *App) PickADIFMonitorFile() (string, error) {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return "", fmt.Errorf("no app context")
|
||||||
|
}
|
||||||
|
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||||
|
Title: "Choose an ADIF file to monitor",
|
||||||
|
Filters: []wruntime.FileFilter{
|
||||||
|
{DisplayName: "ADIF (*.adi;*.adif)", Pattern: "*.adi;*.adif"},
|
||||||
|
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adifMonitorLoop polls the enabled ADIF files every few seconds and imports any
|
||||||
|
// newly appended QSOs. Runs for the app's lifetime on its own goroutine.
|
||||||
|
func (a *App) adifMonitorLoop() {
|
||||||
|
tick := time.NewTicker(5 * time.Second)
|
||||||
|
defer tick.Stop()
|
||||||
|
for range tick.C {
|
||||||
|
if a.ctx == nil || a.qso == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.scanADIFMonitors()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanADIFMonitors walks the enabled files once, importing new records and
|
||||||
|
// persisting advanced offsets.
|
||||||
|
func (a *App) scanADIFMonitors() {
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
cfg := a.loadADIFMonitorLocked()
|
||||||
|
a.adifMonMu.Unlock()
|
||||||
|
if !cfg.Enabled || len(cfg.Files) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// path → new offset, for the files we advanced this pass.
|
||||||
|
advanced := map[string]int64{}
|
||||||
|
for i := range cfg.Files {
|
||||||
|
f := &cfg.Files[i]
|
||||||
|
if !f.Enabled || strings.TrimSpace(f.Path) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(f.Path)
|
||||||
|
if err != nil {
|
||||||
|
continue // not present (yet) — try again next tick
|
||||||
|
}
|
||||||
|
size := fi.Size()
|
||||||
|
if f.Offset < 0 {
|
||||||
|
// First sight of this file → skip whatever history it already holds.
|
||||||
|
advanced[f.Path] = size
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if size < f.Offset {
|
||||||
|
f.Offset = 0 // truncated / rotated → re-read from the start (dedup protects us)
|
||||||
|
}
|
||||||
|
if size == f.Offset {
|
||||||
|
continue // nothing new
|
||||||
|
}
|
||||||
|
newOff, n := a.importADIFAppend(f.Path, f.Offset, size)
|
||||||
|
if newOff != f.Offset {
|
||||||
|
advanced[f.Path] = newOff
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
applog.Printf("adif monitor: imported %d QSO(s) from %s", n, f.Path)
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "adifmon:imported", map[string]any{"file": f.Path, "count": n})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(advanced) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Persist the advanced offsets WITHOUT clobbering a concurrent UI save of the
|
||||||
|
// file list: re-read the stored config and only patch offsets for paths that
|
||||||
|
// still exist there.
|
||||||
|
a.adifMonMu.Lock()
|
||||||
|
cur := a.loadADIFMonitorLocked()
|
||||||
|
for i := range cur.Files {
|
||||||
|
if off, ok := advanced[strings.TrimSpace(cur.Files[i].Path)]; ok {
|
||||||
|
cur.Files[i].Offset = off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.saveADIFMonitorLocked(cur)
|
||||||
|
a.adifMonMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// importADIFAppend reads bytes [from,to) of an ADIF file, imports every COMPLETE
|
||||||
|
// record found (up to the last <eor>) and returns the new offset (just past that
|
||||||
|
// last <eor>) plus how many QSOs were actually imported (duplicates excluded).
|
||||||
|
func (a *App) importADIFAppend(path string, from, to int64) (int64, int) {
|
||||||
|
fh, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return from, 0
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
if _, err := fh.Seek(from, io.SeekStart); err != nil {
|
||||||
|
return from, 0
|
||||||
|
}
|
||||||
|
buf := make([]byte, to-from)
|
||||||
|
nRead, err := io.ReadFull(fh, buf)
|
||||||
|
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||||
|
return from, 0
|
||||||
|
}
|
||||||
|
buf = buf[:nRead]
|
||||||
|
|
||||||
|
// Only consume up to the last complete <eor>; a half-written trailing record
|
||||||
|
// waits for the next poll.
|
||||||
|
lower := bytes.ToLower(buf)
|
||||||
|
last := bytes.LastIndex(lower, []byte(eorTag))
|
||||||
|
if last < 0 {
|
||||||
|
return from, 0 // no complete record yet
|
||||||
|
}
|
||||||
|
end := last + len(eorTag)
|
||||||
|
chunk := buf[:end]
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for _, rec := range splitADIFRecords(chunk) {
|
||||||
|
if strings.TrimSpace(rec) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := a.LogUDPLoggedADIF(rec); err == nil {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
// A duplicate (already in the log within ±2 min) returns an error and is
|
||||||
|
// simply not counted — expected when the same QSO is also logged in OpsLog.
|
||||||
|
}
|
||||||
|
return from + int64(end), count
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitADIFRecords cuts an ADIF byte slice into individual record texts, each
|
||||||
|
// ending at its <eor> (case-insensitive). Any leading file header (up to the
|
||||||
|
// first record's <eor>) rides along with the first record — LogUDPLoggedADIF
|
||||||
|
// parses past an <EOH> header fine, and prepends one when there is none.
|
||||||
|
func splitADIFRecords(b []byte) []string {
|
||||||
|
lower := bytes.ToLower(b)
|
||||||
|
var out []string
|
||||||
|
start := 0
|
||||||
|
for {
|
||||||
|
rel := bytes.Index(lower[start:], []byte(eorTag))
|
||||||
|
if rel < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end := start + rel + len(eorTag)
|
||||||
|
out = append(out, string(b[start:end]))
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
+10
-11
@@ -437,17 +437,16 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
|
|||||||
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
|
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field — NOT
|
||||||
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
|
// the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay independent.
|
||||||
// independent. q came straight from GetByID, so a full Update rewrites the
|
//
|
||||||
// row unchanged apart from this field.
|
// Stamp ONLY this extras key (targeted UPDATE), never a full-row write. The `q`
|
||||||
if q.Extras == nil {
|
// read up top is now stale after the slow e-mail send, and rewriting the whole
|
||||||
q.Extras = map[string]string{}
|
// row would revert any column an auto-upload changed meanwhile — that's how
|
||||||
}
|
// sending a QSL card was flipping clublog_qso_upload_status back from Y to R.
|
||||||
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
|
if err := a.qso.SetExtra(a.ctx, qsoID, appQSLCardSentField, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
if err := a.qso.Update(a.ctx, q); err != nil {
|
applog.Printf("qsl: card sent to %s but marking failed: %v", q.Callsign, err)
|
||||||
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
|
return fmt.Errorf("QSL card sent but status not saved: %w", err)
|
||||||
return fmt.Errorf("eQSL sent but status not saved: %w", err)
|
|
||||||
}
|
}
|
||||||
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
|
||||||
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/award"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The catalog is the channel through which a shipped award is both DELIVERED and
|
||||||
|
// CORRECTED. These tests pin the three rules that make that safe:
|
||||||
|
// - a new catalog award reaches an operator who already has awards stored;
|
||||||
|
// - a fixed one (higher Version) replaces the stored copy;
|
||||||
|
// - unless the operator has edited it, in which case their work wins.
|
||||||
|
|
||||||
|
func catalogDef(t *testing.T, code string) award.Def {
|
||||||
|
t.Helper()
|
||||||
|
for _, d := range award.Defaults() {
|
||||||
|
if d.Code == code {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("%s is not in the catalog", code)
|
||||||
|
return award.Def{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findDef(defs []award.Def, code string) (award.Def, bool) {
|
||||||
|
for _, d := range defs {
|
||||||
|
if d.Code == code {
|
||||||
|
return d, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return award.Def{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeCatalogAddsMissingAward(t *testing.T) {
|
||||||
|
stored := []award.Def{{Code: "MYOWN", Name: "Mine", Valid: true}}
|
||||||
|
got, updated, changed := mergeCatalog(stored)
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("merge reported no change, but every catalog award was missing")
|
||||||
|
}
|
||||||
|
if len(updated) != 0 {
|
||||||
|
t.Errorf("updated = %v, want none: an ADDED award is not an UPDATED one", updated)
|
||||||
|
}
|
||||||
|
if _, ok := findDef(got, "FFMA"); !ok {
|
||||||
|
t.Error("FFMA was not added — a newly shipped award never reaches an existing install")
|
||||||
|
}
|
||||||
|
if _, ok := findDef(got, "MYOWN"); !ok {
|
||||||
|
t.Error("the operator's own award was dropped by the merge")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeCatalogUpdatesOlderVersion(t *testing.T) {
|
||||||
|
// The stored copy is an older revision of a shipped award, with a broken rule.
|
||||||
|
old := catalogDef(t, "FFMA")
|
||||||
|
old.Version = catalogDef(t, "FFMA").Version - 1
|
||||||
|
old.Field = "wrong"
|
||||||
|
old.Valid = false // the operator disabled it — a preference, not a definition
|
||||||
|
|
||||||
|
got, updated, changed := mergeCatalog([]award.Def{old})
|
||||||
|
if !changed || len(updated) == 0 {
|
||||||
|
t.Fatal("a higher catalog version did not replace the stored definition — a shipped award could never be FIXED")
|
||||||
|
}
|
||||||
|
d, _ := findDef(got, "FFMA")
|
||||||
|
if d.Field != "grid4" {
|
||||||
|
t.Errorf("FFMA field = %q, want the catalog's %q", d.Field, "grid4")
|
||||||
|
}
|
||||||
|
if d.Valid {
|
||||||
|
t.Error("the update re-enabled an award the operator had switched off; that is their choice to make, not ours")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeCatalogSkipsUserEdited(t *testing.T) {
|
||||||
|
old := catalogDef(t, "FFMA")
|
||||||
|
old.Version = catalogDef(t, "FFMA").Version - 1
|
||||||
|
old.Field = "mine"
|
||||||
|
old.UserEdited = true
|
||||||
|
|
||||||
|
got, updated, _ := mergeCatalog([]award.Def{old})
|
||||||
|
if len(updated) != 0 {
|
||||||
|
t.Fatalf("updated = %v: an award the operator has edited must never be overwritten", updated)
|
||||||
|
}
|
||||||
|
if d, _ := findDef(got, "FFMA"); d.Field != "mine" {
|
||||||
|
t.Errorf("field = %q, want the operator's %q", d.Field, "mine")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkUserEditedOnlyOnRealChange(t *testing.T) {
|
||||||
|
prev := []award.Def{
|
||||||
|
{Code: "A", Name: "A", Field: "state", Valid: true, Version: 2},
|
||||||
|
{Code: "B", Name: "B", Field: "cqz", Valid: true, Version: 2},
|
||||||
|
}
|
||||||
|
next := []award.Def{
|
||||||
|
{Code: "A", Name: "A", Field: "state", Valid: true}, // untouched
|
||||||
|
{Code: "B", Name: "B", Field: "county", Valid: true}, // changed
|
||||||
|
{Code: "C", Name: "C", Field: "note", Valid: true}, // brand new
|
||||||
|
}
|
||||||
|
markUserEdited(next, prev)
|
||||||
|
|
||||||
|
if next[0].UserEdited {
|
||||||
|
t.Error("A was flagged as edited although nothing about it changed — every save would freeze every award out of future updates")
|
||||||
|
}
|
||||||
|
if !next[1].UserEdited {
|
||||||
|
t.Error("B changed field and was not flagged; a catalog update would overwrite the operator's work")
|
||||||
|
}
|
||||||
|
if next[2].UserEdited {
|
||||||
|
t.Error("C is a brand-new award; there is no shipped version to protect it from")
|
||||||
|
}
|
||||||
|
// A save must not pretend to be a new shipped revision.
|
||||||
|
if next[0].Version != 2 || next[1].Version != 2 {
|
||||||
|
t.Errorf("versions = %d/%d, want both 2: saving is not shipping", next[0].Version, next[1].Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// One-shot generator: reads the FIPS county CSV and emits
|
||||||
|
// internal/awardref/uscounties_gen.go. Not part of the build.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/award"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dxccForState maps the two US "states" that are separate DXCC entities.
|
||||||
|
func dxccForState(st string) int {
|
||||||
|
switch st {
|
||||||
|
case "AK":
|
||||||
|
return 6
|
||||||
|
case "HI":
|
||||||
|
return 110
|
||||||
|
default:
|
||||||
|
return 291
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// territories we exclude entirely — plus DC, which USA-CA does not count as a
|
||||||
|
// county.
|
||||||
|
var skipState = map[string]bool{"PR": true, "GU": true, "VI": true, "AS": true, "MP": true, "NA": true, "DC": true}
|
||||||
|
|
||||||
|
// excludedUSACA reports county-equivalents that the CQ USA-CA award does NOT
|
||||||
|
// count as counties: the independent cities of Virginia and Carson City (NV).
|
||||||
|
// (Baltimore MD and St. Louis MO ARE counted, so they are kept.) A contact in
|
||||||
|
// one of these counts toward a bordering county under the award rules.
|
||||||
|
func excludedUSACA(state, name string) bool {
|
||||||
|
n := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
switch state {
|
||||||
|
case "VA":
|
||||||
|
return strings.HasSuffix(n, " city")
|
||||||
|
case "NV":
|
||||||
|
return n == "carson city"
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
f, err := os.Open(os.Args[1])
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
type row struct{ code, name string; dxcc int }
|
||||||
|
var rows []row
|
||||||
|
seen := map[string]bool{}
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Scan() // header
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
parts := strings.SplitN(line, ",", 3)
|
||||||
|
if len(parts) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(parts[1])
|
||||||
|
st := strings.TrimSpace(parts[2])
|
||||||
|
if skipState[st] || len(st) != 2 || strings.EqualFold(name, "UNITED STATES") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if excludedUSACA(st, name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// State header rows have an all-caps state NAME and state code "NA"
|
||||||
|
// (already skipped). County rows have a 2-letter state code.
|
||||||
|
code := award.USCountyKey(st, name)
|
||||||
|
if code == "" || seen[code] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[code] = true
|
||||||
|
rows = append(rows, row{code: code, name: name + ", " + st, dxcc: dxccForState(st)})
|
||||||
|
}
|
||||||
|
sort.Slice(rows, func(i, j int) bool { return rows[i].code < rows[j].code })
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("// Code generated by cmd/cntygen from FIPS county data. DO NOT EDIT.\n")
|
||||||
|
b.WriteString("package awardref\n\n")
|
||||||
|
b.WriteString("// usCounties is the US Counties (USA-CA) reference list: one entry per county\n")
|
||||||
|
b.WriteString("// in the 50 states, keyed by the canonical \"STATE,COUNTY\" match code that\n")
|
||||||
|
b.WriteString("// award.usCountyKey produces from a QSO's state + cnty fields.\n")
|
||||||
|
fmt.Fprintf(&b, "func usCounties() []Ref {\n\treturn []Ref{\n")
|
||||||
|
for _, r := range rows {
|
||||||
|
fmt.Fprintf(&b, "\t\tref(%q, %q, %d),\n", r.code, r.name, r.dxcc)
|
||||||
|
}
|
||||||
|
b.WriteString("\t}\n}\n")
|
||||||
|
|
||||||
|
if err := os.WriteFile(os.Args[2], []byte(b.String()), 0o644); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("wrote %d counties to %s\n", len(rows), os.Args[2])
|
||||||
|
}
|
||||||
+1194
-303
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,30 @@
|
|||||||
import { Antenna, X } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
import { Antenna, X, ListFilter } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
export type AGAntenna = { index: number; name: string };
|
export type AGAntenna = { index: number; name: string; bands?: number };
|
||||||
export type AGStatus = {
|
export type AGStatus = {
|
||||||
connected: boolean; host?: string; last_error?: string;
|
connected: boolean; host?: string; last_error?: string;
|
||||||
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
|
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
|
||||||
antennas: AGAntenna[];
|
antennas: AGAntenna[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// AG_BAND_BITS maps a band name to its bit in the 4O3A Antenna Genius band
|
||||||
|
// bitmask (standard HF+6m order, bit 0 = 160m). Used to show only the antennas
|
||||||
|
// configured for the current band, like the native app. If the mapping ever
|
||||||
|
// proves off on real hardware, the debug log (antgenius: antenna raw …) shows the
|
||||||
|
// device's actual mask so it can be corrected.
|
||||||
|
const AG_BAND_BITS: Record<string, number> = {
|
||||||
|
'160m': 1 << 0, '80m': 1 << 1, '60m': 0, '40m': 1 << 2, '30m': 1 << 3,
|
||||||
|
'20m': 1 << 4, '17m': 1 << 5, '15m': 1 << 6, '12m': 1 << 7, '10m': 1 << 8, '6m': 1 << 9,
|
||||||
|
};
|
||||||
|
|
||||||
|
function bandBit(band?: string): number {
|
||||||
|
if (!band) return 0;
|
||||||
|
return AG_BAND_BITS[band.trim().toLowerCase()] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Format an antenna name: first letter uppercase, the rest lowercase
|
// Format an antenna name: first letter uppercase, the rest lowercase
|
||||||
// (e.g. "DX COMMANDER" → "Dx commander").
|
// (e.g. "DX COMMANDER" → "Dx commander").
|
||||||
function pretty(name: string): string {
|
function pretty(name: string): string {
|
||||||
@@ -17,39 +33,64 @@ 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
|
||||||
// port A, blue = selected on port B, red (pulsing) = that port is transmitting.
|
// port A, blue = selected on port B, red (pulsing) = that port is transmitting.
|
||||||
// Clicking an already-selected port deselects it (port → None).
|
// Clicking an already-selected port deselects it (port → None).
|
||||||
export function AntGeniusPanel({ status, onActivate, onClose }: {
|
export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
||||||
status: AGStatus;
|
status: AGStatus;
|
||||||
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
|
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
band?: string; // current operating band (e.g. "20m") for the band filter
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const list = status.antennas ?? [];
|
const allAntennas = status.antennas ?? [];
|
||||||
|
|
||||||
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
// Band filter (persisted, default ON): show only antennas whose band mask
|
||||||
const letter = port === 1 ? 'A' : 'B';
|
// includes the current band — matching the native 4O3A app. Antennas with an
|
||||||
const cls = tx
|
// unknown mask (0) always show, and if the filter would hide EVERYTHING (e.g. a
|
||||||
? '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'
|
// band we can't map, or masks the device didn't send) we fall back to the full
|
||||||
: active
|
// list so the panel is never mysteriously empty.
|
||||||
? (port === 1
|
const [bandFilter, setBandFilter] = useState(() => localStorage.getItem('opslog.agBandFilter') !== '0');
|
||||||
? '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)]'
|
const toggleBandFilter = () => setBandFilter((v) => { const n = !v; localStorage.setItem('opslog.agBandFilter', n ? '1' : '0'); return n; });
|
||||||
: '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)]')
|
const bit = bandBit(band);
|
||||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
let list = allAntennas;
|
||||||
return (
|
if (bandFilter && bit !== 0) {
|
||||||
<button
|
const filtered = allAntennas.filter((a) => !a.bands || (a.bands & bit) !== 0);
|
||||||
type="button"
|
if (filtered.length > 0) list = filtered;
|
||||||
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">
|
||||||
@@ -57,6 +98,11 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
|
|||||||
<Antenna className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
|
<Antenna className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
|
||||||
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
|
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
|
<button type="button" onClick={toggleBandFilter}
|
||||||
|
title={bandFilter ? t('agp.filterOnHint', { band: band || '' }) : t('agp.filterOffHint')}
|
||||||
|
className={cn('transition-colors', bandFilter ? 'text-primary' : 'text-muted-foreground/50 hover:text-muted-foreground')}>
|
||||||
|
<ListFilter className="size-3.5" />
|
||||||
|
</button>
|
||||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
|
||||||
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
|
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
|
||||||
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('agp.online') : t('agp.offline')}</span>
|
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('agp.online') : t('agp.offline')}</span>
|
||||||
@@ -88,11 +134,11 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
|
|||||||
: '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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search } from 'lucide-react';
|
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle } from 'lucide-react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -18,7 +18,9 @@ import {
|
|||||||
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
||||||
ListCountries, DXCCForCountry, DXCCName,
|
ListCountries, DXCCForCountry, DXCCName,
|
||||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||||
ExportAwards, ImportAwards,
|
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||||
|
ExportAwardForCatalog,
|
||||||
|
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
// Above this many references the editor stops loading the whole list and
|
// Above this many references the editor stops loading the whole list and
|
||||||
@@ -32,11 +34,11 @@ export type AwardDef = {
|
|||||||
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
||||||
ref_display?: string; // grid column shows: ref | name | both
|
ref_display?: string; // grid column shows: ref | name | both
|
||||||
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
||||||
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
|
leading_str?: string; trailing_str?: string; dynamic?: boolean;
|
||||||
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 = {
|
||||||
@@ -154,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
|
||||||
@@ -177,7 +182,91 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
loadMeta();
|
loadMeta();
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
// Codes present in the SHIPPED catalog. Anything in the database that isn't in
|
||||||
|
// here exists only on this machine — it is yours alone, and a reinstall loses it
|
||||||
|
// unless it has been exported. The list flags those.
|
||||||
|
const [catalogCodes, setCatalogCodes] = useState<string[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
GetCatalogCodes().then((c: any) => setCatalogCodes(((c ?? []) as string[]).map((s) => s.toUpperCase()))).catch(() => {});
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Shipped fixes we did NOT apply, because this award carries the operator's own
|
||||||
|
// changes and we will not overwrite those behind their back. Offered, not forced.
|
||||||
|
type AwardUpdate = { code: string; name: string; from: number; to: number };
|
||||||
|
const [updates, setUpdates] = useState<AwardUpdate[]>([]);
|
||||||
|
const loadUpdates = useCallback(() => {
|
||||||
|
GetAwardUpdates().then((u: any) => setUpdates(Array.isArray(u) ? u : [])).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { if (open) loadUpdates(); }, [open, loadUpdates]);
|
||||||
|
|
||||||
|
// Pending import awaiting the operator's decision on the awards that collide.
|
||||||
|
type ImportEntry = { code: string; name: string; references: number; exists: boolean; mine_name: string; mine_refs: number; protected: boolean };
|
||||||
|
type ImportPreview = { path: string; awards: ImportEntry[] };
|
||||||
|
const [importPreview, setImportPreview] = useState<ImportPreview | null>(null);
|
||||||
|
const [decisions, setDecisions] = useState<Record<string, string>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!importPreview) return;
|
||||||
|
// Default to the SAFE choice: keep what you have. An import must never destroy
|
||||||
|
// an award because the operator clicked through a dialog without reading it.
|
||||||
|
const d: Record<string, string> = {};
|
||||||
|
for (const e of importPreview.awards) d[e.code] = e.exists ? 'skip' : 'replace';
|
||||||
|
setDecisions(d);
|
||||||
|
}, [importPreview]);
|
||||||
|
|
||||||
const cur = defs[sel];
|
const cur = defs[sel];
|
||||||
|
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.
|
||||||
|
type Rejected = { candidate: string; reason: string };
|
||||||
|
type Step = {
|
||||||
|
rule: string; field: string; match_by?: string; exact?: boolean; pattern?: string;
|
||||||
|
field_value?: string; candidates?: string[]; kept?: string[]; rejected?: Rejected[];
|
||||||
|
skipped?: boolean; error?: string;
|
||||||
|
};
|
||||||
|
type Explanation = {
|
||||||
|
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
|
||||||
|
ref_count: number; steps: Step[]; manual?: string[]; result: string[];
|
||||||
|
};
|
||||||
|
type TestRow = { qso: any; explanation: Explanation };
|
||||||
|
const [testCall, setTestCall] = useState('');
|
||||||
|
const [testRows, setTestRows] = useState<TestRow[] | null>(null);
|
||||||
|
const [testErr, setTestErr] = useState('');
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const runTest = async () => {
|
||||||
|
if (!cur) return;
|
||||||
|
setTesting(true); setTestErr(''); setTestRows(null);
|
||||||
|
try {
|
||||||
|
const r = await ExplainAward(cur.code, testCall);
|
||||||
|
setTestRows((Array.isArray(r) ? r : []) as TestRow[]);
|
||||||
|
} catch (e: any) {
|
||||||
|
setTestErr(String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// The tester reads the SAVED award, not the unsaved edits in this dialog — so say
|
||||||
|
// so, rather than let the operator test a rule they only think they applied.
|
||||||
|
useEffect(() => { setTestRows(null); setTestErr(''); }, [sel]);
|
||||||
|
|
||||||
|
// Several QSOs with the same station usually trace IDENTICALLY, and twenty copies
|
||||||
|
// of the same trace is noise. But they don't always: scope is judged per QSO
|
||||||
|
// (band, mode, date — FFMA's 1983 cut-off), a manual override lives ON a QSO, and
|
||||||
|
// two contacts can even hold different QTHs. That divergence is often the actual
|
||||||
|
// answer ("why does my 2019 QSO count and my 2024 one not?"), so we group by
|
||||||
|
// trace instead of dropping it: one card per distinct outcome, with its count.
|
||||||
|
const testGroups = useMemo(() => {
|
||||||
|
if (!testRows) return null;
|
||||||
|
const groups: { key: string; rows: TestRow[] }[] = [];
|
||||||
|
for (const row of testRows) {
|
||||||
|
const key = JSON.stringify(row.explanation);
|
||||||
|
const g = groups.find((x) => x.key === key);
|
||||||
|
if (g) g.rows.push(row);
|
||||||
|
else groups.push({ key, rows: [row] });
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [testRows]);
|
||||||
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
|
||||||
const toggleIn = (key: keyof AwardDef, v: string) => {
|
const toggleIn = (key: keyof AwardDef, v: string) => {
|
||||||
const arr = ((cur?.[key] as string[]) ?? []);
|
const arr = ((cur?.[key] as string[]) ?? []);
|
||||||
@@ -215,17 +304,52 @@ 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)); }
|
||||||
}
|
}
|
||||||
// Import an award bundle: definitions are upserted by code, reference lists
|
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
||||||
// replaced. Reloads the editor afterwards.
|
// 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.
|
||||||
|
//
|
||||||
|
// This used to merge by code with "imported wins", silently — import a WAPC
|
||||||
|
// someone shared and YOUR WAPC (its province list, its city regexes) was
|
||||||
|
// destroyed without a word. Sharing awards is exactly what we want people to do,
|
||||||
|
// so it must not be a data-loss trap.
|
||||||
async function importAwards() {
|
async function importAwards() {
|
||||||
setErr('');
|
setErr('');
|
||||||
try {
|
try {
|
||||||
const r: any = await ImportAwards();
|
const p: any = await InspectAwardImport();
|
||||||
if (!r || (!r.awards && !r.references)) return; // cancelled
|
if (!p?.path) return; // cancelled
|
||||||
|
const clashes = (p.awards ?? []).filter((e: any) => e.exists);
|
||||||
|
if (clashes.length === 0) {
|
||||||
|
// Nothing collides — nothing to ask. This is the common case.
|
||||||
|
const dec: Record<string, string> = {};
|
||||||
|
for (const e of p.awards) dec[e.code] = 'replace';
|
||||||
|
await applyImport(p.path, dec);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImportPreview(p); // → the collision dialog decides
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyImport(path: string, decisions: Record<string, string>) {
|
||||||
|
try {
|
||||||
|
const r: any = await ApplyAwardImport(path, decisions);
|
||||||
|
setImportPreview(null);
|
||||||
const [d] = await Promise.all([GetAwardDefs(), loadMeta()]);
|
const [d] = await Promise.all([GetAwardDefs(), loadMeta()]);
|
||||||
setDefs((d ?? []) as any); setSel(0);
|
setDefs((d ?? []) as any); setSel(0);
|
||||||
onSaved();
|
onSaved();
|
||||||
setErr(t('awed.importedMsg', { awards: r.awards, references: r.references }));
|
if (r?.awards || r?.references) {
|
||||||
|
setErr(t('awed.importedMsg', { awards: r.awards, references: r.references }));
|
||||||
|
}
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function updateList(code: string) {
|
async function updateList(code: string) {
|
||||||
@@ -246,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>
|
||||||
@@ -261,15 +385,37 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{filtered.map(({ d, i }) => (
|
{filtered.map(({ d, i }) => {
|
||||||
<button key={i} onClick={() => setSel(i)}
|
// An award that is NOT in the shipped catalog exists only in THIS
|
||||||
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
// database: nobody else has it, and a reinstall loses it unless it
|
||||||
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
|
// has been exported. That deserves to be visible at a glance, not
|
||||||
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
|
// discovered the hard way.
|
||||||
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
const onlyHere = !catalogCodes.includes((d.code ?? '').toUpperCase());
|
||||||
<span className="text-muted-foreground truncate">{d.name}</span>
|
// A pending update is only reachable from the award's own banner, so
|
||||||
</button>
|
// the list has to say which award to open — otherwise the fix waits
|
||||||
))}
|
// behind a click nobody knows to make.
|
||||||
|
const hasUpdate = updates.some((u) => (u.code ?? '').toUpperCase() === (d.code ?? '').toUpperCase());
|
||||||
|
return (
|
||||||
|
<button key={i} onClick={() => setSel(i)}
|
||||||
|
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
||||||
|
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
|
||||||
|
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
|
||||||
|
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
||||||
|
<span className="text-muted-foreground truncate">{d.name}</span>
|
||||||
|
{hasUpdate && (
|
||||||
|
<span className="ml-auto shrink-0" title={t('awed.updateAvailable')}>
|
||||||
|
<ArrowUpCircle className="size-3.5 text-info" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{onlyHere && (
|
||||||
|
<span className="ml-auto shrink-0 px-1 rounded border border-warning-border bg-warning-muted text-warning-muted-foreground text-[9px] font-semibold uppercase tracking-wide"
|
||||||
|
title={t('awed.onlyHereTip')}>
|
||||||
|
{t('awed.onlyHere')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
|
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
|
||||||
<Plus className="size-3.5 mr-1" /> {t('awed.newAward')}
|
<Plus className="size-3.5 mr-1" /> {t('awed.newAward')}
|
||||||
@@ -279,6 +425,37 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
{/* Right: tabbed editor for selected award */}
|
{/* Right: tabbed editor for selected award */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||||
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
|
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
|
||||||
|
{/* A fix shipped for an award this operator has customised. We did NOT
|
||||||
|
apply it — that would destroy their work — so we offer it, and say
|
||||||
|
plainly what accepting costs. */}
|
||||||
|
{cur && selUpdate && (
|
||||||
|
<div className="mx-4 mt-3 rounded border border-info/40 bg-info/10 px-3 py-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ArrowUpCircle className="size-4 text-info shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium">{t('awed.updateAvailable')}</div>
|
||||||
|
<div className="text-muted-foreground">{t('awed.updateOverwrites')}</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="h-7 px-2 text-[11px]"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await ApplyAwardUpdate(cur.code);
|
||||||
|
// The backend rewrote this award (and its references) — pull the
|
||||||
|
// new state back, or the editor would keep showing, and on the
|
||||||
|
// next Save re-persist, the definition we just replaced.
|
||||||
|
setDefs(((await GetAwardDefs()) ?? []) as any);
|
||||||
|
loadMeta();
|
||||||
|
loadUpdates();
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}}>{t('awed.updateApply')}</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-[11px]"
|
||||||
|
onClick={async () => {
|
||||||
|
try { await DismissAwardUpdate(cur.code); loadUpdates(); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}}>{t('awed.updateKeepMine')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!cur ? (
|
{!cur ? (
|
||||||
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
|
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -288,6 +465,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
|
||||||
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
|
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
|
||||||
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
|
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="test">{t('awed.tabTest')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<div className="flex-1 overflow-auto p-4">
|
||||||
@@ -297,6 +475,15 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
||||||
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
|
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
|
||||||
|
{/* No "Built-in" checkbox: an award OpsLog ships IS built-in, and
|
||||||
|
the catalog derives that on load. Asking the author to tick a
|
||||||
|
box to declare it would be one more step nobody can guess —
|
||||||
|
forget it and the award silently misses every future catalog
|
||||||
|
correction. "Protected" stays: whether an award can be deleted
|
||||||
|
IS a real choice. */}
|
||||||
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.protectedTip')}>
|
||||||
|
<Checkbox checked={!!cur.protected} onCheckedChange={(c) => patch({ protected: !!c })} /> {t('awed.protectedFlag')}
|
||||||
|
</label>
|
||||||
<button className="text-muted-foreground hover:text-destructive" title={t('awed.deleteAward')} onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
|
<button className="text-muted-foreground hover:text-destructive" title={t('awed.deleteAward')} onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
||||||
@@ -338,7 +525,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
}</SelectContent>
|
}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field2>
|
</Field2>
|
||||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.multi} onCheckedChange={(c) => patch({ multi: !!c })} /> {t('awed.allowMultiple')}</label>
|
{/* No "allow multiple references" switch: a QSO always counts for
|
||||||
|
every reference its field holds (an n-fer POTA activation, a
|
||||||
|
VUCC grid-line contact). The old checkbox was read by nothing. */}
|
||||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> {t('awed.dynamicRefs')}</label>
|
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> {t('awed.dynamicRefs')}</label>
|
||||||
|
|
||||||
<div className="border-t pt-2.5 mt-1 space-y-2.5">
|
<div className="border-t pt-2.5 mt-1 space-y-2.5">
|
||||||
@@ -365,8 +554,10 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Additional OR searches: a QSO earns a reference if the
|
{/* Fallback searches: tried in order, only while nothing
|
||||||
primary rule OR any of these match. */}
|
has matched yet — the first that hits wins (short-circuit),
|
||||||
|
so a value already resolved by the primary rule isn't
|
||||||
|
re-derived differently by a later one. */}
|
||||||
<div className="border-t pt-2.5 space-y-2">
|
<div className="border-t pt-2.5 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
|
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
|
||||||
@@ -423,8 +614,105 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Field2 label={t('awed.grantCodes')}><Input className="h-8" value={cur.grant_codes ?? ''} onChange={(e) => patch({ grant_codes: e.target.value })} /></Field2>
|
{/* "Grant codes" and "export credit_granted" used to live here. No
|
||||||
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.export_credit_granted} onCheckedChange={(c) => patch({ export_credit_granted: !!c })} /> {t('awed.exportCreditGranted')}</label>
|
ADIF export has ever written CREDIT_GRANTED, so both controls
|
||||||
|
did nothing at all. The stored values are kept (see award.Def);
|
||||||
|
put the controls back when the export is actually wired up. */}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── Test ──
|
||||||
|
Runs the SAVED award against a real QSO and shows every rule:
|
||||||
|
what it scanned, what it produced, and why each candidate was
|
||||||
|
rejected. An award that matches nothing used to be a black box. */}
|
||||||
|
<TabsContent value="test" className="mt-0 space-y-3">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">{t('awed.testCallsign')}</Label>
|
||||||
|
<Input className="h-8 w-40 font-mono uppercase" value={testCall} placeholder="I2IFT"
|
||||||
|
onChange={(e) => setTestCall(e.target.value.toUpperCase())}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') runTest(); }} />
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="h-8" onClick={runTest} disabled={testing || !testCall.trim()}>
|
||||||
|
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Search className="size-3.5 mr-1" />}
|
||||||
|
{t('awed.testRun')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground pb-1">{t('awed.testSavedOnly')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testErr && <div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5">{testErr}</div>}
|
||||||
|
|
||||||
|
{testGroups?.map((g, ri) => {
|
||||||
|
const row = g.rows[0];
|
||||||
|
const ex = row.explanation;
|
||||||
|
const matched = (ex.result ?? []).length > 0;
|
||||||
|
const qsoLabel = (q: any) => `${String(q?.qso_date ?? '').slice(0, 10)} · ${q?.band} · ${q?.mode}`;
|
||||||
|
return (
|
||||||
|
<div key={ri} className="rounded border border-border overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-xs border-b border-border">
|
||||||
|
<span className="font-mono font-semibold">{row.qso?.callsign}</span>
|
||||||
|
<span className="text-muted-foreground font-mono">{qsoLabel(row.qso)}</span>
|
||||||
|
{g.rows.length > 1 && (
|
||||||
|
<span className="text-muted-foreground" title={g.rows.slice(1).map((r) => qsoLabel(r.qso)).join('\n')}>
|
||||||
|
{t('awed.testSameAs', { n: String(g.rows.length - 1) })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn('ml-auto px-1.5 rounded text-[10px] font-semibold uppercase tracking-wide',
|
||||||
|
matched ? 'bg-success/15 text-success' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||||
|
{matched ? (ex.result ?? []).join(', ') : t('awed.testNoMatch')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!ex.in_scope ? (
|
||||||
|
<div className="px-3 py-2 text-xs">
|
||||||
|
<span className="font-medium">{t('awed.testOutOfScope')}</span>{' '}
|
||||||
|
<span className="text-muted-foreground">{ex.scope_error}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border/50">
|
||||||
|
{(ex.steps ?? []).map((s, si) => (
|
||||||
|
<div key={si} className={cn('px-3 py-2 text-xs', s.skipped && 'opacity-50')}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="font-semibold uppercase text-[10px] tracking-wide">{s.rule}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{s.field}{s.match_by ? ` / ${s.match_by}` : ''}{s.exact ? ` / ${t('awed.exact')}` : ''}
|
||||||
|
</span>
|
||||||
|
{s.skipped && <span className="text-muted-foreground italic">— {t('awed.testSkipped')}</span>}
|
||||||
|
{s.error && <span className="text-destructive">— {s.error}</span>}
|
||||||
|
</div>
|
||||||
|
{!s.skipped && !s.error && (
|
||||||
|
<div className="mt-1 space-y-0.5 pl-3 border-l-2 border-border">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">{t('awed.testFieldValue')}: </span>
|
||||||
|
{s.field_value
|
||||||
|
? <span className="font-mono">{s.field_value}</span>
|
||||||
|
: <span className="italic text-muted-foreground">{t('awed.testEmptyField')}</span>}
|
||||||
|
</div>
|
||||||
|
{(s.kept ?? []).map((k) => (
|
||||||
|
<div key={k} className="text-success font-mono">✓ {k}</div>
|
||||||
|
))}
|
||||||
|
{(s.rejected ?? []).map((r, i2) => (
|
||||||
|
<div key={i2} className="font-mono text-muted-foreground">
|
||||||
|
✗ {r.candidate} <span className="font-sans">— {r.reason}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!(s.kept ?? []).length && !(s.rejected ?? []).length && s.field_value && (
|
||||||
|
<div className="italic text-muted-foreground">{t('awed.testNoCandidate')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(ex.manual ?? []).length > 0 && (
|
||||||
|
<div className="px-3 py-2 text-xs">
|
||||||
|
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
|
||||||
|
<span className="font-mono text-success">{(ex.manual ?? []).join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ── References ── */}
|
{/* ── References ── */}
|
||||||
@@ -449,11 +737,90 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
||||||
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* The drop folder: put an award JSON here and it installs on restart —
|
||||||
|
no rebuild, nobody to ask. Opening it beats printing a path someone
|
||||||
|
then has to retype. */}
|
||||||
|
<Button variant="ghost" onClick={() => OpenAwardsFolder().catch((e: any) => setErr(String(e?.message ?? e)))}
|
||||||
|
title={t('awed.awardsFolderTip')}>
|
||||||
|
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
|
||||||
|
</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>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
|
{/* Collision dialog. The import cannot silently replace an award you already
|
||||||
|
have — importing a shared WAPC used to destroy yours (province list, city
|
||||||
|
regexes, band scope) without a word. You decide, per award. */}
|
||||||
|
{importPreview && (
|
||||||
|
<Dialog open onOpenChange={() => setImportPreview(null)}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('awed.importCollisionTitle')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-xs text-muted-foreground -mt-2">{t('awed.importCollisionHint')}</p>
|
||||||
|
<div className="max-h-[50vh] overflow-auto flex flex-col gap-2 mt-2">
|
||||||
|
{importPreview.awards.map((e) => (
|
||||||
|
<div key={e.code} className="rounded-md border border-border p-2.5">
|
||||||
|
<div className="flex items-baseline gap-2 min-w-0">
|
||||||
|
<span className="font-mono font-semibold text-sm">{e.code}</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">{e.name}</span>
|
||||||
|
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
{t('awed.importRefs', { n: e.references })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!e.exists ? (
|
||||||
|
<p className="mt-1 text-[11px] text-success-muted-foreground">{t('awed.importNew')}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mt-1 text-[11px] text-warning-muted-foreground">
|
||||||
|
{t('awed.importExists', { name: e.mine_name || e.code, n: e.mine_refs })}
|
||||||
|
{e.protected && ` · ${t('awed.importProtected')}`}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||||
|
{([
|
||||||
|
['skip', t('awed.importKeepMine')],
|
||||||
|
['replace', t('awed.importReplace')],
|
||||||
|
['copy', t('awed.importCopy', { code: e.code })],
|
||||||
|
] as [string, string][]).map(([v, label]) => (
|
||||||
|
<button key={v} type="button"
|
||||||
|
onClick={() => setDecisions((d) => ({ ...d, [e.code]: v }))}
|
||||||
|
className={cn('px-2 h-7 rounded-md border text-xs',
|
||||||
|
decisions[e.code] === v
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: 'border-border hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setImportPreview(null)}>{t('awed.cancel')}</Button>
|
||||||
|
<Button onClick={() => applyImport(importPreview.path, decisions)}>
|
||||||
|
<Upload className="size-3.5 mr-1" /> {t('awed.import2')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -606,26 +973,18 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
<Field2 label={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
<Field2 label={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{/* One per row: side by side, each half spends 120px of its width on
|
||||||
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
the label column and the input is left too narrow to read a group
|
||||||
<Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
name ("Basilicata" → "Basili"). */}
|
||||||
</div>
|
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
||||||
|
<Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
||||||
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
|
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
|
||||||
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} /></Field2>
|
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} /></Field2>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
{/* Score / Bonus were here. Nothing computes an award score, so both
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
boxes were pure decoration. The columns stay in the database — a
|
||||||
<Label className="text-xs text-muted-foreground">{t('awed.score')}</Label>
|
third-party list may carry the values — but they are not offered
|
||||||
<Input type="number" className="h-8 font-mono w-full" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} />
|
for editing until something actually reads them. */}
|
||||||
</div>
|
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
|
||||||
<Label className="text-xs text-muted-foreground">{t('awed.bonus')}</Label>
|
|
||||||
<Input type="number" className="h-8 font-mono w-full" value={sel.bonus ?? 0} onChange={(e) => patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
|
||||||
<Label className="text-xs text-muted-foreground">{t('awed.grid')}</Label>
|
|
||||||
<Input className="h-8 font-mono w-full" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
|
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
|
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_name', label: 'bulk.fMyName', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_arrl_sect', label: 'bulk.fMyArrlSect', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_darc_dok', label: 'bulk.fMyDarcDok', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_vucc_grids', label: 'bulk.fMyVuccGrids', group: 'My station', kind: 'text', upper: true },
|
||||||
// Contest
|
// Contest
|
||||||
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
|
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
|
||||||
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
|
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
|
||||||
@@ -60,7 +64,9 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'prop_mode', label: 'bulk.fPropMode', group: 'Propagation', kind: 'text', upper: true },
|
{ id: 'prop_mode', label: 'bulk.fPropMode', group: 'Propagation', kind: 'text', upper: true },
|
||||||
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
|
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
|
||||||
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
|
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
|
||||||
// Contacted station (activation refs / SIG)
|
// Contacted station (location / activation refs / SIG)
|
||||||
|
{ id: 'state', label: 'bulk.fState', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
|
{ id: 'cnty', label: 'bulk.fCnty', group: 'Contacted station', kind: 'text' },
|
||||||
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
{ id: 'sota_ref', label: 'bulk.fSotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
{ id: 'sota_ref', label: 'bulk.fSotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
{ id: 'wwff_ref', label: 'bulk.fWwffRef', group: 'Contacted station', kind: 'text', upper: true },
|
{ id: 'wwff_ref', label: 'bulk.fWwffRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export type SpotStatusEntry = {
|
|||||||
country?: string;
|
country?: string;
|
||||||
continent?: string;
|
continent?: string;
|
||||||
worked_call?: boolean;
|
worked_call?: boolean;
|
||||||
|
new_county?: boolean;
|
||||||
|
new_pota?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -101,13 +103,43 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
|||||||
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
||||||
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
||||||
// there's nothing notable to show.
|
// there's nothing notable to show.
|
||||||
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): { text: string; fg: string; bg: string } | null {
|
type Badge = { text: string; fg: string; bg: string; bd: string };
|
||||||
|
|
||||||
|
// tok builds a badge from a semantic status token (success/warning/caution/
|
||||||
|
// danger/info/neutral) so the colours adapt to every theme via CSS variables,
|
||||||
|
// instead of the hard-coded light-theme pastels that looked wrong in dark mode.
|
||||||
|
function tok(name: string, text: string): Badge {
|
||||||
|
if (name === 'neutral') return { text, fg: 'var(--muted-foreground)', bg: 'var(--muted)', bd: 'var(--border)' };
|
||||||
|
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the
|
||||||
|
// Status badges) when the slot is notable, instead of flooding the whole cell with
|
||||||
|
// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is
|
||||||
|
// null → plain text, no pill.
|
||||||
|
function cellChip(value: any, name: string | null): any {
|
||||||
|
const txt = value === undefined || value === null || value === '' ? '' : String(value);
|
||||||
|
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||||
|
// Inherit the column's font size (no fixed 9px / height) so a pill around a
|
||||||
|
// callsign stays the same size as the plain callsigns next to it — just tinted
|
||||||
|
// and rounded, not shrunk.
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', lineHeight: 1.1,
|
||||||
|
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
|
||||||
|
border: `1px solid var(--${name}-border)`, fontWeight: 700,
|
||||||
|
padding: '1px 6px', borderRadius: 999, whiteSpace: 'nowrap',
|
||||||
|
}}>{txt}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
|
||||||
switch (s?.status) {
|
switch (s?.status) {
|
||||||
case 'new': return { text: t('clg2.newDxcc'), fg: '#be123c', bg: '#ffe4e6' };
|
case 'new': return tok('danger', t('clg2.newDxcc'));
|
||||||
case 'new-band': return { text: t('clg2.newBand'), fg: '#92400e', bg: '#fde68a' };
|
case 'new-band': return tok('warning', t('clg2.newBand'));
|
||||||
case 'new-mode': return { text: t('clg2.newMode'), fg: '#854d0e', bg: '#fef08a' };
|
case 'new-mode': return tok('caution', t('clg2.newMode'));
|
||||||
case 'new-slot': return { text: t('clg2.newSlot'), fg: '#854d0e', bg: '#fef08a' };
|
case 'new-slot': return tok('caution', t('clg2.newSlot'));
|
||||||
default: return s?.worked_call ? { text: t('clg2.wkdCall'), fg: '#0369a1', bg: '#e0f2fe' } : null;
|
default: return s?.worked_call ? tok('neutral', t('clg2.wkdCall')) : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,22 +149,22 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
sort: 'desc',
|
sort: 'desc',
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
|
group: 'Spot', label: t('clg2.c.call'), colId: 'call',
|
||||||
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
// Only STATUS calls get a colour: new DXCC entity → filled cell (no padded
|
// NEW DXCC → a danger pill around the call, consistent with the NEW BAND /
|
||||||
// pill, so calls stay aligned), worked-call → blue. A plain spot inherits the
|
// NEW MODE pills (same look, same tokens). Worked-call stays blue bold text;
|
||||||
// theme's normal text colour (var(--foreground)) so callsigns blend in with
|
// a plain spot inherits the theme's normal text colour so callsigns blend in
|
||||||
// the rest of the row across every theme instead of always shouting orange.
|
// with the rest of the row instead of always shouting a colour.
|
||||||
cellStyle: (p: any): any => {
|
cellRenderer: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
if (s?.status === 'new') return { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 };
|
if (s?.status === 'new') return cellChip(p.value, 'danger');
|
||||||
if (s?.worked_call) return { color: '#0369a1', fontWeight: 700 };
|
const color = s?.worked_call ? 'var(--info)' : undefined;
|
||||||
return { fontWeight: 700 };
|
return <span style={{ color, fontWeight: 700 }}>{p.value ?? ''}</span>;
|
||||||
},
|
},
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
@@ -141,26 +173,42 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
||||||
headerName: t('clg2.c.status'), width: 96, sortable: true,
|
headerName: t('clg2.c.status'), width: 120, sortable: true,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
||||||
// is obvious at the row level, not just a single coloured cell.
|
// is obvious at the row level, not just a single coloured cell. NEW COUNTY
|
||||||
|
// and NEW POTA are orthogonal, so they stack as extra badges.
|
||||||
valueGetter: (p: any) => {
|
valueGetter: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
if (s?.status === 'new') return t('clg2.newDxcc');
|
const parts: string[] = [];
|
||||||
if (s?.status === 'new-band') return t('clg2.newBand');
|
if (s?.status === 'new') parts.push(t('clg2.newDxcc'));
|
||||||
if (s?.status === 'new-mode') return t('clg2.newMode');
|
else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
|
||||||
if (s?.status === 'new-slot') return t('clg2.newSlot');
|
else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
|
||||||
return s?.worked_call ? t('clg2.wkdCall') : '';
|
else if (s?.status === 'new-slot') parts.push(t('clg2.newSlot'));
|
||||||
|
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
|
||||||
|
if (s?.new_county) parts.push(t('clg2.newCounty'));
|
||||||
|
if (s?.new_pota) parts.push(t('clg2.newPota'));
|
||||||
|
return parts.join(' ');
|
||||||
},
|
},
|
||||||
cellRenderer: (p: any) => {
|
cellRenderer: (p: any) => {
|
||||||
const b = statusBadge(t, statusFor(p));
|
const s = statusFor(p);
|
||||||
if (!b) return <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>;
|
const badges: Badge[] = [];
|
||||||
|
const b = statusBadge(t, s);
|
||||||
|
if (b) badges.push(b);
|
||||||
|
if (s?.new_county) badges.push(tok('info', t('clg2.newCounty')));
|
||||||
|
if (s?.new_pota) badges.push(tok('success', t('clg2.newPota')));
|
||||||
|
if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||||
return (
|
return (
|
||||||
<span style={{
|
<span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
|
||||||
backgroundColor: b.bg, color: b.fg, fontWeight: 700, fontSize: 10,
|
{badges.map((bd, i) => (
|
||||||
padding: '1px 6px', borderRadius: 4, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
<span key={i} style={{
|
||||||
}}>{b.text}</span>
|
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
|
||||||
|
backgroundColor: bd.bg, color: bd.fg, border: `1px solid ${bd.bd}`,
|
||||||
|
fontWeight: 700, fontSize: 9,
|
||||||
|
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
||||||
|
}}>{bd.text}</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
@@ -176,7 +224,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
||||||
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: { color: '#166534' },
|
cellStyle: { color: 'var(--success)' },
|
||||||
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -191,10 +239,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
// NEW BAND for this entity → fill the cell (keeps the band text aligned).
|
// NEW BAND for this entity → small warning pill around the band text.
|
||||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band'
|
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null),
|
||||||
? { backgroundColor: '#fde68a', color: '#92400e', fontWeight: 700 }
|
|
||||||
: undefined),
|
|
||||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -203,16 +249,11 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||||
// Fill the mode cell: teal = NEW MODE (mode never worked on this entity),
|
// Only NEW MODE pills the mode cell — there the mode itself is genuinely new
|
||||||
// yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
|
// for the entity. NEW SLOT means band AND mode were each worked before (just
|
||||||
cellStyle: (p: any) => {
|
// not together), so highlighting the mode cell would wrongly imply "CW is new";
|
||||||
const st = statusFor(p)?.status;
|
// that case is signalled by the Status badge alone.
|
||||||
// Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the
|
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null),
|
||||||
// Status badge text tells them apart.
|
|
||||||
if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 };
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const st = statusFor(p)?.status;
|
const st = statusFor(p)?.status;
|
||||||
if (st === 'new-mode') return t('clg2.tipNewMode');
|
if (st === 'new-mode') return t('clg2.tipNewMode');
|
||||||
@@ -224,7 +265,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
|
||||||
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
|
||||||
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
|
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
|
||||||
@@ -261,7 +302,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
|
valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
|
||||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||||
]?.country ?? '',
|
]?.country ?? '',
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
|
group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
|
||||||
@@ -270,31 +311,31 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
|
valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
|
||||||
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
|
||||||
]?.continent ?? '',
|
]?.continent ?? '',
|
||||||
cellStyle: { color: '#7a6b50', fontSize: 10 },
|
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
|
group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
|
||||||
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
valueFormatter: (p) => cleanSpotter(p.value ?? ''),
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
|
group: 'Spot', label: t('clg2.c.source'), colId: 'source',
|
||||||
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
|
headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: { color: '#9a8870', fontSize: 10 },
|
cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
|
group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
|
||||||
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
|
group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
|
||||||
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
|
headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: { color: '#7a6b50' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
|
group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
|||||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
|
||||||
<Mic className="size-3.5 text-primary" />
|
<Mic className="size-3.5 text-primary" />
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
|
||||||
<span className={cn('size-2 rounded-full', status.playing ? 'bg-warning animate-pulse' : 'bg-success')} />
|
<span className={cn('size-2 rounded-full', status.playing ? 'bg-danger animate-pulse' : 'bg-success')} />
|
||||||
{status.playing && <span className="text-[10px] text-warning font-medium">tx...</span>}
|
{status.playing && <span className="text-[10px] text-danger font-medium">TX</span>}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
||||||
<Square className="size-3" /> {t('dvkp.stop')}
|
<Square className="size-3" /> {t('dvkp.stop')}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
|
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
@@ -134,6 +134,12 @@ interface Props {
|
|||||||
|
|
||||||
export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
// Field dropdown sorted alphabetically by translated label (the FIELDS array is
|
||||||
|
// grouped by category for maintenance; the operator wants it A→Z to find things).
|
||||||
|
const sortedFields = useMemo(
|
||||||
|
() => FIELDS.map((f) => ({ f, txt: t(f.label) })).sort((a, b) => a.txt.localeCompare(b.txt)),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
const [conditions, setConditions] = useState<FilterCondition[]>([]);
|
const [conditions, setConditions] = useState<FilterCondition[]>([]);
|
||||||
const [match, setMatch] = useState<'AND' | 'OR'>('AND');
|
const [match, setMatch] = useState<'AND' | 'OR'>('AND');
|
||||||
const [presets, setPresets] = useState<Record<string, QueryFilter>>({});
|
const [presets, setPresets] = useState<Record<string, QueryFilter>>({});
|
||||||
@@ -237,7 +243,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
}}>
|
}}>
|
||||||
<SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent className="max-h-72">
|
<SelectContent className="max-h-72">
|
||||||
{FIELDS.map((f) => <SelectItem key={f.value} value={f.value}>{t(f.label)}</SelectItem>)}
|
{sortedFields.map(({ f, txt }) => <SelectItem key={f.value} value={f.value}>{txt}</SelectItem>)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}>
|
<Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}>
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ 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,
|
||||||
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,
|
||||||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { sMeterRST } from '@/lib/rst';
|
import { sMeterRST } from '@/lib/rst';
|
||||||
@@ -24,7 +30,15 @@ type FlexState = {
|
|||||||
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
|
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
|
||||||
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
|
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
|
||||||
split: boolean; rx_freq_hz?: number; tx_freq_hz?: number;
|
split: boolean; rx_freq_hz?: number; tx_freq_hz?: 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;
|
||||||
|
// 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[];
|
||||||
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;
|
||||||
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
|
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
|
||||||
@@ -34,14 +48,16 @@ type FlexState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type FlexSlice = { index: number; letter: string; freq_hz: number; mode?: string; band?: string; active: boolean; tx: boolean };
|
type FlexSlice = { index: number; letter: string; freq_hz: number; mode?: string; band?: string; active: boolean; tx: boolean };
|
||||||
type Meter = { id: number; src?: string; name?: string; unit?: string; value: number; lo: number; hi: number };
|
type Meter = { id: number; src?: string; name?: string; unit?: string; slice?: number; value: number; lo: number; hi: number };
|
||||||
|
|
||||||
const ZERO: FlexState = {
|
const ZERO: FlexState = {
|
||||||
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
|
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
|
||||||
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
|
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
|
||||||
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
|
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
|
||||||
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
|
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
|
||||||
|
rit: false, rit_freq: 0, xit: false, xit_freq: 0,
|
||||||
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
|
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
|
||||||
|
wnb: false, wnb_level: 0, tx_filter_low: 0, tx_filter_high: 0,
|
||||||
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
|
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
|
||||||
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
|
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
|
||||||
amp_available: false, amp_operate: false,
|
amp_available: false, amp_operate: false,
|
||||||
@@ -139,31 +155,93 @@ function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, slide
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OffsetRow — RIT / XIT: a switch plus one signed offset field you scrub with the
|
||||||
|
// wheel (or ± , or the arrow keys once focused; hold Ctrl/Shift for 100 Hz steps).
|
||||||
|
// Deliberately the same control as the Icom panel's ShiftRow, so the two rigs are
|
||||||
|
// driven identically.
|
||||||
|
//
|
||||||
|
// The offset is NOT cleared when the switch goes off: SmartSDR keeps it, so
|
||||||
|
// flipping RIT back on returns you exactly where you were, like the radio's own
|
||||||
|
// knob. Zeroing it is a separate, deliberate act — that is what the 0 button is for.
|
||||||
|
const OFFSET_MAX = 99999; // SmartSDR's limit
|
||||||
|
function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
|
||||||
|
label: string; on: boolean; onToggle: () => void; hz: number; onHz: (v: number) => void;
|
||||||
|
disabled?: boolean; title?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const step = useRef((_d: number) => {});
|
||||||
|
step.current = (d: number) => onHz(Math.max(-OFFSET_MAX, Math.min(OFFSET_MAX, (hz || 0) + d)));
|
||||||
|
|
||||||
|
// React's onWheel is passive, so preventDefault() there is ignored and the panel
|
||||||
|
// scrolls under the cursor instead of the value changing. Bind it natively.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const mag = e.ctrlKey || e.shiftKey ? 100 : 10;
|
||||||
|
step.current(e.deltaY < 0 ? mag : -mag);
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onKey = (e: React.KeyboardEvent) => {
|
||||||
|
const up = e.key === 'ArrowUp' || e.key === 'ArrowRight';
|
||||||
|
const dn = e.key === 'ArrowDown' || e.key === 'ArrowLeft';
|
||||||
|
if (!up && !dn) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const mag = e.ctrlKey || e.shiftKey ? 100 : 10;
|
||||||
|
step.current(up ? mag : -mag);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2" title={title}>
|
||||||
|
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent="cyan" />
|
||||||
|
<div ref={ref} data-offsetrow tabIndex={disabled || !on ? -1 : 0} onKeyDown={onKey}
|
||||||
|
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-info/50',
|
||||||
|
on && !disabled ? 'border-border bg-muted/40 cursor-ns-resize' : 'border-border/60 bg-muted/20 opacity-60')}>
|
||||||
|
<button type="button" disabled={disabled || !on} onClick={() => step.current(-10)}
|
||||||
|
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:hover:text-muted-foreground">−</button>
|
||||||
|
<span className={cn('text-sm font-mono font-bold tabular-nums', on && hz ? 'text-info' : 'text-muted-foreground')}>
|
||||||
|
{hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz || 0)} Hz
|
||||||
|
</span>
|
||||||
|
<button type="button" disabled={disabled || !on} onClick={() => step.current(10)}
|
||||||
|
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:hover:text-muted-foreground">+</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" disabled={disabled || !hz} onClick={() => onHz(0)}
|
||||||
|
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted disabled:opacity-30">0</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
|
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
|
||||||
// `display` overrides the numeric readout; `segColor` colours segments by their
|
// `display` overrides the numeric readout; `segColor` colours segments by their
|
||||||
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
||||||
const METER_SEGMENTS = 26;
|
const METER_SEGMENTS = 26;
|
||||||
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title }: {
|
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
|
||||||
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
||||||
segColor?: (frac: number) => string; onClick?: () => void; title?: string;
|
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const span = hi - lo;
|
const span = hi - lo;
|
||||||
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
||||||
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
||||||
return (
|
return (
|
||||||
<div onClick={onClick} title={title}
|
<div onClick={onClick} title={title}
|
||||||
className={cn('rounded-lg border border-border/70 px-2.5 py-2 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
||||||
|
compact ? 'px-2 py-1' : 'px-2.5 py-2',
|
||||||
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
|
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
|
||||||
<div className="flex items-baseline justify-between gap-1 mb-1.5">
|
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
||||||
<span className="text-sm font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90">
|
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
|
||||||
{display !== undefined ? display : (
|
{display !== undefined ? display : (
|
||||||
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
||||||
<div className="flex gap-[2px] h-3 items-stretch rounded-[3px] bg-black/10 p-[2px]">
|
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
|
||||||
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
||||||
const on = i < lit;
|
const on = i < lit;
|
||||||
const frac = i / METER_SEGMENTS;
|
const frac = i / METER_SEGMENTS;
|
||||||
@@ -176,7 +254,7 @@ function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, displ
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{extra && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
{extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -193,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 }>>({});
|
||||||
@@ -240,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 }));
|
||||||
@@ -248,6 +383,39 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
|
|
||||||
const off = !st.available;
|
const off = !st.available;
|
||||||
const rxOff = off || !st.rx_avail;
|
const rxOff = off || !st.rx_avail;
|
||||||
|
|
||||||
|
// Radio-style global RIT tuning: Ctrl+←/→ nudges the RIT offset (Ctrl+Shift =
|
||||||
|
// ±100 Hz) from ANYWHERE — including while the callsign/RST entry field has
|
||||||
|
// focus, which is where the operator is while working a station. Only the RIT
|
||||||
|
// offset row itself (data-offsetrow) is skipped, since it handles its own
|
||||||
|
// arrows. Text-field word navigation via Ctrl+← / → is overridden only while
|
||||||
|
// RIT is actually engaged (guarded below), which is fine mid-QSO. Requires RIT
|
||||||
|
// on and the RX available.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (!(e.ctrlKey || e.metaKey)) return;
|
||||||
|
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||||
|
const ae = document.activeElement as HTMLElement | null;
|
||||||
|
if (ae && ae.hasAttribute('data-offsetrow')) return;
|
||||||
|
if (!st.rit || rxOff) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const mag = e.shiftKey ? 100 : 10;
|
||||||
|
const next = (st.rit_freq || 0) + (e.key === 'ArrowRight' ? mag : -mag);
|
||||||
|
change('rit_freq', next, () => FlexSetRITFreq(next));
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [st.rit, st.rit_freq, rxOff]);
|
||||||
|
|
||||||
|
// Clear the RIT offset back to 0 whenever a QSO is logged (any path: Log
|
||||||
|
// button, keyer macro, WSJT-X), so the next station starts centred. Only acts
|
||||||
|
// when there's actually a non-zero offset to clear.
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('qso:logged', () => {
|
||||||
|
if (st.rit && (st.rit_freq || 0) !== 0) change('rit_freq', 0, () => FlexSetRITFreq(0));
|
||||||
|
});
|
||||||
|
return () => off();
|
||||||
|
}, [st.rit, st.rit_freq]);
|
||||||
const isCW = (st.mode || '').toUpperCase().includes('CW');
|
const isCW = (st.mode || '').toUpperCase().includes('CW');
|
||||||
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
||||||
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
||||||
@@ -262,6 +430,13 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0])
|
? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0])
|
||||||
: -1;
|
: -1;
|
||||||
|
|
||||||
|
// Compact header readouts for PA voltage + temperature (numbers, not bars) — the
|
||||||
|
// user wanted these in the header to save vertical space in the Meters card.
|
||||||
|
const hdrMeters = st.meters || [];
|
||||||
|
const hdrFind = (name: string) => hdrMeters.find((m) => (m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
|
||||||
|
const voltM = hdrFind('13.8') || hdrFind('VOLT');
|
||||||
|
const patempM = hdrFind('PATEMP') || hdrFind('PA TEMP');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full min-h-0 overflow-auto bg-background">
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||||
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||||
@@ -274,6 +449,23 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
<span className="text-[11px] text-slate-400">{t('flxp.smartsdrRemote')}</span>
|
<span className="text-[11px] text-slate-400">{t('flxp.smartsdrRemote')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
{/* Compact PA voltage + temperature readouts (numbers) to save space. */}
|
||||||
|
{!off && (voltM || patempM) && (
|
||||||
|
<div className="flex items-center gap-4 mr-1 font-mono tabular-nums">
|
||||||
|
{voltM && (
|
||||||
|
<span className="flex flex-col items-end leading-none">
|
||||||
|
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-sans">{t('flxp.voltage')}</span>
|
||||||
|
<span className="text-sm font-bold">{voltM.value.toFixed(1)}<span className="text-[9px] text-slate-400 ml-0.5">V</span></span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{patempM && (
|
||||||
|
<span className="flex flex-col items-end leading-none">
|
||||||
|
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-sans">{t('flxp.paTemp')}</span>
|
||||||
|
<span className={cn('text-sm font-bold', patempM.value >= 70 ? 'text-red-400' : patempM.value >= 55 ? 'text-amber-400' : 'text-slate-100')}>{patempM.value.toFixed(0)}<span className="text-[9px] text-slate-400 ml-0.5">°C</span></span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
|
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
|
||||||
!st.available ? 'bg-muted text-muted-foreground'
|
!st.available ? 'bg-muted text-muted-foreground'
|
||||||
: st.transmitting ? 'bg-danger text-danger-foreground shadow-[0_0_16px] shadow-danger/60' : 'bg-success text-success-foreground')}>
|
: st.transmitting ? 'bg-danger text-danger-foreground shadow-[0_0_16px] shadow-danger/60' : 'bg-success text-success-foreground')}>
|
||||||
@@ -315,6 +507,66 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Live meters (UDP VITA-49 stream) — placed right under the slices. */}
|
||||||
|
<Card icon={Gauge} title={t('flxp.meters')}>
|
||||||
|
{(() => {
|
||||||
|
const meters = st.meters || [];
|
||||||
|
if (off || meters.length === 0) {
|
||||||
|
return <p className="text-[11px] text-muted-foreground text-center py-2">{t('flxp.noMeters')}</p>;
|
||||||
|
}
|
||||||
|
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
|
||||||
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
|
// Radio meters (exclude the amplifier's, which we show separately).
|
||||||
|
const radio = (name: string) => meters.find((m) =>
|
||||||
|
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
|
||||||
|
// Per-slice (SLC) meters — S-meter — exist once PER SLICE, so pick the
|
||||||
|
// one for the ACTIVE slice; otherwise we'd always show slice A's level.
|
||||||
|
const activeSlice = (st.slices || []).find((s) => s.active)?.index ?? -1;
|
||||||
|
const sliceMeter = (name: string) => {
|
||||||
|
const m = meters.filter((x) => (x.name || '').toUpperCase().includes(name) && !(x.src || '').toUpperCase().includes('AMP'));
|
||||||
|
if (m.length === 0) return undefined;
|
||||||
|
return m.find((x) => (x.src || '').toUpperCase().includes('SLC') && x.slice === activeSlice) || m[0];
|
||||||
|
};
|
||||||
|
const sig = sliceMeter('LEVEL') || sliceMeter('SIGNAL');
|
||||||
|
const fwd = radio('FWDPWR');
|
||||||
|
const swr = radio('SWR');
|
||||||
|
// Mic input level + speech-compression (voltage & PA temp live in the
|
||||||
|
// header now). All already in the meter stream.
|
||||||
|
const mic = meters.find((m) => /MIC/i.test(m.name || '') && !(m.src || '').toUpperCase().includes('AMP'));
|
||||||
|
const comp = radio('COMPPEAK') || radio('COMP');
|
||||||
|
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
|
||||||
|
const sUnit = (dbm: number) => {
|
||||||
|
const sv = (dbm + 127) / 6; // S0 = -127 dBm
|
||||||
|
if (sv >= 9) {
|
||||||
|
const over = Math.max(0, Math.round(dbm + 73)); // dB over S9
|
||||||
|
return { display: over > 0 ? `S9+${over}` : 'S9', bar: sv, s: 9, over };
|
||||||
|
}
|
||||||
|
return { display: `S${Math.max(0, Math.round(sv))}`, bar: Math.max(0, sv), s: Math.max(0, sv), over: 0 };
|
||||||
|
};
|
||||||
|
const cur = [
|
||||||
|
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
|
||||||
|
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
|
||||||
|
title={onReportRST ? t('rst.clickToFill') : undefined}
|
||||||
|
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
|
||||||
|
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
|
||||||
|
); })(),
|
||||||
|
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
|
||||||
|
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
||||||
|
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
|
||||||
|
); })(),
|
||||||
|
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
|
||||||
|
// Mic input level: fixed -40..+10 dB scale, RED from 0 dB up (overdrive).
|
||||||
|
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={10} accent="#16a34a"
|
||||||
|
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
|
||||||
|
// Speech compression (dB of gain reduction).
|
||||||
|
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={(comp.hi && comp.hi > 0) ? comp.hi : 25} accent="#0891b2" />,
|
||||||
|
].filter(Boolean);
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Card>
|
||||||
|
|
||||||
{off && (
|
{off && (
|
||||||
<div className="text-center text-sm text-muted-foreground py-6">
|
<div className="text-center text-sm text-muted-foreground py-6">
|
||||||
{t('flxp.waiting')}
|
{t('flxp.waiting')}
|
||||||
@@ -365,6 +617,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
)}
|
)}
|
||||||
</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">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -391,6 +644,39 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
<Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} />
|
<Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} />
|
||||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* TX audio bandwidth — low/high cut (Hz). Typed locally, committed on
|
||||||
|
blur/Enter so we don't spam a command per keystroke; the hold guard
|
||||||
|
keeps the poll from clobbering the field mid-edit. */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.txFilter')}</span>
|
||||||
|
{(() => {
|
||||||
|
const editTX = (patch: Partial<FlexState>) => { hold.current['tx_filter_low'] = Date.now() + 8000; hold.current['tx_filter_high'] = Date.now() + 8000; setSt((p) => ({ ...p, ...patch })); };
|
||||||
|
const commitTX = () => { hold.current['tx_filter_low'] = Date.now() + 900; hold.current['tx_filter_high'] = Date.now() + 900; FlexSetTXFilter(st.tx_filter_low || 0, st.tx_filter_high || 0).catch(() => {}); };
|
||||||
|
const inp = 'w-[70px] h-8 rounded-md border border-input bg-background px-2 text-xs font-mono tabular-nums disabled:opacity-40';
|
||||||
|
return (<>
|
||||||
|
<input type="number" step={50} min={0} disabled={off} value={st.tx_filter_low || 0} className={inp}
|
||||||
|
onChange={(e) => editTX({ tx_filter_low: parseInt(e.target.value, 10) || 0 })}
|
||||||
|
onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} />
|
||||||
|
<span className="text-muted-foreground text-xs">–</span>
|
||||||
|
<input type="number" step={50} min={0} disabled={off} value={st.tx_filter_high || 0} className={inp}
|
||||||
|
onChange={(e) => editTX({ tx_filter_high: parseInt(e.target.value, 10) || 0 })}
|
||||||
|
onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} />
|
||||||
|
<span className="text-[10px] text-muted-foreground/70 font-mono">Hz</span>
|
||||||
|
</>);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
{/* Mic profile — SmartSDR named mic-EQ/processor presets. */}
|
||||||
|
{!!st.mic_profiles && st.mic_profiles.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.micProfile')}</span>
|
||||||
|
<select disabled={off} value={st.mic_profile || ''}
|
||||||
|
onChange={(e) => { const v = e.target.value; hold.current['mic_profile'] = Date.now() + 2000; setSt((p) => ({ ...p, mic_profile: v })); FlexSetMicProfile(v).catch(() => {}); }}
|
||||||
|
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-2 text-xs">
|
||||||
|
{!st.mic_profiles.includes(st.mic_profile || '') && <option value="">—</option>}
|
||||||
|
{st.mic_profiles.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
|
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
|
||||||
@@ -415,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>
|
||||||
@@ -435,6 +733,14 @@ 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>
|
||||||
)}
|
)}
|
||||||
@@ -464,6 +770,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
|
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
|
||||||
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
|
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
|
||||||
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
|
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
|
||||||
|
{/* WNB — wideband noise blanker (the extra hardware NR the Flex has). */}
|
||||||
|
<LevelRow label="WNB" on={st.wnb} disabled={rxOff} value={st.wnb_level} accent="amber" sliderAccent="#f59e0b"
|
||||||
|
onToggle={() => change('wnb', !st.wnb, () => FlexSetWNB(!st.wnb))}
|
||||||
|
onLevel={(v) => change('wnb_level', v, () => FlexSetWNBLevel(v))} />
|
||||||
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
|
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
|
||||||
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
|
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
|
||||||
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
|
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
|
||||||
@@ -474,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"
|
||||||
@@ -523,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',
|
||||||
@@ -539,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>
|
||||||
@@ -560,71 +1021,39 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span>
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
|
||||||
|
and shown compact so they sit with the amp controls. */}
|
||||||
|
{(() => {
|
||||||
|
const meters = st.meters || [];
|
||||||
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
|
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||||
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||||
|
if (off || amp.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
|
{amp.map((m) => {
|
||||||
|
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
|
||||||
|
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
|
||||||
|
}
|
||||||
|
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||||
|
// Drain current (ID): the PGXL reports a full-scale far too small
|
||||||
|
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
|
||||||
|
// value itself is fine — only the bar's range was wrong. Give it a
|
||||||
|
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
|
||||||
|
// only override an unusable reported range, not a sane one.
|
||||||
|
let lo = m.lo, hi = m.hi;
|
||||||
|
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
||||||
|
lo = 0;
|
||||||
|
hi = m.hi >= 25 ? m.hi : 25;
|
||||||
|
}
|
||||||
|
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Live meters (UDP VITA-49 stream) */}
|
|
||||||
<Card icon={Gauge} title={t('flxp.meters')}>
|
|
||||||
{(() => {
|
|
||||||
const meters = st.meters || [];
|
|
||||||
if (off || meters.length === 0) {
|
|
||||||
return <p className="text-[11px] text-muted-foreground text-center py-2">{t('flxp.noMeters')}</p>;
|
|
||||||
}
|
|
||||||
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
|
|
||||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
|
||||||
// Radio meters (exclude the amplifier's, which we show separately).
|
|
||||||
const radio = (name: string) => meters.find((m) =>
|
|
||||||
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
|
|
||||||
const sig = radio('LEVEL') || radio('SIGNAL');
|
|
||||||
const fwd = radio('FWDPWR');
|
|
||||||
const swr = radio('SWR');
|
|
||||||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
|
||||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
|
||||||
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626'
|
|
||||||
: /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c'
|
|
||||||
: /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
|
||||||
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
|
|
||||||
const sUnit = (dbm: number) => {
|
|
||||||
const sv = (dbm + 127) / 6; // S0 = -127 dBm
|
|
||||||
if (sv >= 9) {
|
|
||||||
const over = Math.max(0, Math.round(dbm + 73)); // dB over S9
|
|
||||||
return { display: over > 0 ? `S9+${over}` : 'S9', bar: sv, s: 9, over };
|
|
||||||
}
|
|
||||||
return { display: `S${Math.max(0, Math.round(sv))}`, bar: Math.max(0, sv), s: Math.max(0, sv), over: 0 };
|
|
||||||
};
|
|
||||||
const cur = [
|
|
||||||
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
|
|
||||||
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
|
|
||||||
title={onReportRST ? t('rst.clickToFill') : undefined}
|
|
||||||
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
|
|
||||||
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
|
|
||||||
); })(),
|
|
||||||
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
|
|
||||||
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
|
||||||
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
|
|
||||||
); })(),
|
|
||||||
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
|
|
||||||
].filter(Boolean);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
|
||||||
{amp.length > 0 && (
|
|
||||||
<div className="border-t border-border/60 pt-2 mt-1">
|
|
||||||
<div className="text-[10px] font-bold tracking-wider text-muted-foreground mb-1.5">{t('flxp.amplifierHdr')}</div>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
|
||||||
{amp.map((m) => {
|
|
||||||
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) {
|
|
||||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />;
|
|
||||||
}
|
|
||||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -58,6 +58,21 @@ const BANDS: { l: string; hz: number }[] = [
|
|||||||
// SSB by frequency and the rig's data variant for digital modes.
|
// SSB by frequency and the rig's data variant for digital modes.
|
||||||
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM'];
|
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM'];
|
||||||
|
|
||||||
|
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
|
||||||
|
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
|
||||||
|
// dB stepped attenuator; the IC-7300/705/7100 have a single 20 dB attenuator; the
|
||||||
|
// IC-9700 a single 10 dB. Offering the wrong steps = a dead button (the rig NAKs
|
||||||
|
// e.g. 6 dB on a 7300). Default to the common single 20 dB for unknown models.
|
||||||
|
function attOptions(model?: string): { v: string; l: string }[] {
|
||||||
|
const m = (model ?? '').toUpperCase();
|
||||||
|
const OFF = { v: '0', l: 'OFF' };
|
||||||
|
if (/(7610|7700|7800|7850|7851)/.test(m)) {
|
||||||
|
return [OFF, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
||||||
|
}
|
||||||
|
if (m.includes('9700')) return [OFF, { v: '10', l: '10dB' }];
|
||||||
|
return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default
|
||||||
|
}
|
||||||
|
|
||||||
// fmtVFO renders a Hz frequency the way an Icom front panel does:
|
// fmtVFO renders a Hz frequency the way an Icom front panel does:
|
||||||
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
|
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
|
||||||
function fmtVFO(hz?: number): string {
|
function fmtVFO(hz?: number): string {
|
||||||
@@ -534,7 +549,7 @@ function ScopePanadapter() {
|
|||||||
// Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are
|
// Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are
|
||||||
// read every cache cycle; DSP set-controls are optimistic and reconcile on the
|
// read every cache cycle; DSP set-controls are optimistic and reconcile on the
|
||||||
// next poll. Front-panel knob changes for DSP show after ↻ Refresh.
|
// next poll. Front-panel knob changes for DSP show after ↻ Refresh.
|
||||||
export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void } = {}) {
|
export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [st, setSt] = useState<IcomState>(ZERO);
|
const [st, setSt] = useState<IcomState>(ZERO);
|
||||||
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
||||||
@@ -637,16 +652,22 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
{st.split ? <span className="rounded-md bg-warning/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">Split</span> : null}
|
{st.split ? <span className="rounded-md bg-warning/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">Split</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{/* Radio power ON / OFF. Manual by design — the app never wakes the rig
|
{/* Radio power ON / OFF — NETWORK only. Over USB the CI-V interface is
|
||||||
on connect; ON sends the wake preamble then the rig boots ~15 s. */}
|
unpowered while the rig is off, so power-ON can't reach it (OFF works
|
||||||
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
|
but ON doesn't); hiding both avoids a dead button. On the network the
|
||||||
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
|
rig's LAN server stays alive in standby, so both work. */}
|
||||||
<Power className="size-3.5" /> ON
|
{isNetwork && (
|
||||||
</button>
|
<>
|
||||||
<button type="button" onClick={() => IcomSetPower(false).catch(() => {})} title={t('icmp.powerOffHint')}
|
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
|
||||||
className="inline-flex items-center gap-1 rounded-md border border-destructive/60 bg-destructive/10 px-2 py-1 text-xs font-bold text-destructive hover:bg-destructive/20">
|
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
|
||||||
<Power className="size-3.5" /> OFF
|
<Power className="size-3.5" /> ON
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onClick={() => IcomSetPower(false).catch(() => {})} title={t('icmp.powerOffHint')}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-destructive/60 bg-destructive/10 px-2 py-1 text-xs font-bold text-destructive hover:bg-destructive/20">
|
||||||
|
<Power className="size-3.5" /> OFF
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -804,7 +825,7 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
|
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
|
||||||
</Row>
|
</Row>
|
||||||
<Row label="Att">
|
<Row label="Att">
|
||||||
<Segmented value={String(st.att)} options={[{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]}
|
<Segmented value={String(st.att)} options={attOptions(cat?.rig)}
|
||||||
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
||||||
</Row>
|
</Row>
|
||||||
<Row label={t('icmp.filter')}>
|
<Row label={t('icmp.filter')}>
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ function loadBasemap(): BasemapKey {
|
|||||||
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
|
||||||
|
// place-name overlay. updateWhenIdle/keepBuffer keep the number of live tiles
|
||||||
|
// down: satellite loads TWO tile layers, so its tile count — and the composited
|
||||||
|
// layers WebView2 has to hold — is double every other basemap's.
|
||||||
|
function addBasemap(
|
||||||
|
m: L.Map,
|
||||||
|
key: BasemapKey,
|
||||||
|
base: React.MutableRefObject<L.TileLayer | null>,
|
||||||
|
labels: React.MutableRefObject<L.TileLayer | null>,
|
||||||
|
) {
|
||||||
|
if (base.current) { m.removeLayer(base.current); base.current = null; }
|
||||||
|
if (labels.current) { m.removeLayer(labels.current); labels.current = null; }
|
||||||
|
const bm = BASEMAPS[key];
|
||||||
|
const opts: L.TileLayerOptions = { maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1 };
|
||||||
|
base.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||||||
|
if (bm.labelsUrl) labels.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
||||||
|
}
|
||||||
|
|
||||||
function dot(color: string): L.DivIcon {
|
function dot(color: string): L.DivIcon {
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
@@ -103,11 +121,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
// One-time map creation.
|
// One-time map creation.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (worldRef.current && !worldMap.current) {
|
if (worldRef.current && !worldMap.current) {
|
||||||
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true })
|
// preferCanvas: the beam lobe is a dense FAN of translucent radials — up to
|
||||||
|
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
|
||||||
|
// composited paths re-rasterised on every pan, zoom and redraw, which is
|
||||||
|
// enough to blow WebView2's raster budget and leave the window painting in
|
||||||
|
// patches. On canvas it is a single layer.
|
||||||
|
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true })
|
||||||
.setView([20, 0], 1);
|
.setView([20, 0], 1);
|
||||||
const bm = BASEMAPS[basemap];
|
addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||||
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
|
|
||||||
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
|
|
||||||
worldOverlay.current = L.layerGroup().addTo(m);
|
worldOverlay.current = L.layerGroup().addTo(m);
|
||||||
worldMap.current = m;
|
worldMap.current = m;
|
||||||
const sv = loadMapView();
|
const sv = loadMapView();
|
||||||
@@ -115,7 +136,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
||||||
}
|
}
|
||||||
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
||||||
return () => window.clearTimeout(t);
|
// Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on
|
||||||
|
// every overlay redraw (as before) forced a full repaint of the map each time
|
||||||
|
// the rotor moved a degree.
|
||||||
|
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
|
||||||
|
if (worldRef.current) ro.observe(worldRef.current);
|
||||||
|
return () => { window.clearTimeout(t); ro.disconnect(); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Swap the basemap (and its optional place-name overlay) when the operator
|
// Swap the basemap (and its optional place-name overlay) when the operator
|
||||||
@@ -123,12 +149,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
// overlayPane, always above any tile layer, so nothing to re-stack there.
|
// overlayPane, always above any tile layer, so nothing to re-stack there.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const m = worldMap.current;
|
const m = worldMap.current;
|
||||||
if (!m) return;
|
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||||
if (baseLayer.current) { m.removeLayer(baseLayer.current); baseLayer.current = null; }
|
|
||||||
if (labelsLayer.current) { m.removeLayer(labelsLayer.current); labelsLayer.current = null; }
|
|
||||||
const bm = BASEMAPS[basemap];
|
|
||||||
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
|
|
||||||
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
|
|
||||||
}, [basemap]);
|
}, [basemap]);
|
||||||
|
|
||||||
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||||
@@ -224,9 +245,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
wm.setView([from.lat, from.lon], 3);
|
wm.setView([from.lat, from.lon], 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTimeout(() => { wm.invalidateSize(); }, 0);
|
// No invalidateSize() here — a ResizeObserver handles the only case that needs
|
||||||
|
// it. Forcing a full map repaint on every redraw is what made a moving rotor
|
||||||
|
// thrash the compositor.
|
||||||
|
// Headings are rounded in the deps: a rotor reports a jittering float, and a
|
||||||
|
// tenth of a degree is invisible on a 5500 km lobe but rebuilds every polyline.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom, zoomSignal]);
|
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth,
|
||||||
|
boomAzimuth == null ? null : Math.round(boomAzimuth), autoZoom, zoomSignal]);
|
||||||
|
|
||||||
const path = pathBetween(fromGrid, toGrid);
|
const path = pathBetween(fromGrid, toGrid);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
import {
|
import {
|
||||||
@@ -41,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>('');
|
||||||
@@ -59,14 +74,44 @@ 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[]>([]);
|
||||||
|
// 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);
|
||||||
const [contact, setContact] = useState<Station>(emptyStation());
|
const [contact, setContact] = useState<Station>(emptyStation());
|
||||||
const [looking, setLooking] = useState(false);
|
const [looking, setLooking] = useState(false);
|
||||||
|
|
||||||
const activeGrid = useRef<any>(null);
|
|
||||||
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('');
|
||||||
@@ -176,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).
|
||||||
@@ -230,20 +303,9 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
|
|
||||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
|
||||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
|
||||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
|
||||||
{ headerName: t('ncp.colTimeOn'), valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
|
|
||||||
{ headerName: t('ncp.colBand'), field: 'band', width: 70, cellClass: 'font-mono' },
|
|
||||||
{ headerName: t('ncp.colMode'), field: 'mode', width: 70, cellClass: 'font-mono' },
|
|
||||||
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
|
|
||||||
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
|
|
||||||
{ headerName: t('ncp.colComment'), field: 'comment', flex: 1.5 },
|
|
||||||
], [t]);
|
|
||||||
|
|
||||||
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 },
|
||||||
@@ -292,28 +354,28 @@ 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 style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
|
||||||
<div style={{ position: 'absolute', inset: 0 }}>
|
<RecentQSOsGrid
|
||||||
<AgGridReact<QSOForm>
|
rows={active}
|
||||||
ref={activeGrid}
|
total={active.length}
|
||||||
theme={hamlogTheme}
|
storageKey="net.onair"
|
||||||
rowData={active}
|
selectRowSignal={selectRow}
|
||||||
columnDefs={activeCols}
|
rowDragCall
|
||||||
defaultColDef={defaultColDef}
|
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
|
||||||
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
||||||
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
|
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
||||||
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
onRowSelected={setSelectedActiveIds}
|
||||||
animateRows={false}
|
/>
|
||||||
getRowId={(p) => String((p.data as any).id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{isOpen && active.length > 0 && (
|
{isOpen && active.length > 0 && (
|
||||||
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||||
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
|
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -335,7 +397,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 overflow-auto">
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
{!wbCall ? (
|
{!wbCall ? (
|
||||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div>
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div>
|
||||||
) : wbBusy ? (
|
) : wbBusy ? (
|
||||||
@@ -343,30 +405,12 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
) : !wb || wb.count === 0 ? (
|
) : !wb || wb.count === 0 ? (
|
||||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbNone')} {wbCall}</div>
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbNone')} {wbCall}</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-xs">
|
<RecentQSOsGrid
|
||||||
<thead className="sticky top-0 bg-muted/30 text-muted-foreground">
|
rows={(wb.entries ?? []) as any}
|
||||||
<tr className="text-left">
|
total={wb.count}
|
||||||
<th className="px-3 py-1 font-semibold">{t('ncp.colDate')}</th>
|
storageKey="net.wb"
|
||||||
<th className="px-2 py-1 font-semibold">{t('ncp.colTimeOn')}</th>
|
{...qsoMenuHandlers}
|
||||||
<th className="px-2 py-1 font-semibold">{t('ncp.colBand')}</th>
|
/>
|
||||||
<th className="px-2 py-1 font-semibold">{t('ncp.colMode')}</th>
|
|
||||||
<th className="px-2 py-1 font-semibold">RST</th>
|
|
||||||
<th className="px-2 py-1 font-semibold">{t('ncp.colComment')}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{((wb.entries ?? []) as any[]).map((q, i) => (
|
|
||||||
<tr key={q.id ?? i} className="border-t border-border/20 hover:bg-muted/30">
|
|
||||||
<td className="px-3 py-1 font-mono">{String(q.qso_date ?? '').slice(0, 10)}</td>
|
|
||||||
<td className="px-2 py-1 font-mono">{String(q.time_on ?? '').slice(0, 5)}</td>
|
|
||||||
<td className="px-2 py-1">{q.band}</td>
|
|
||||||
<td className="px-2 py-1">{q.mode}</td>
|
|
||||||
<td className="px-2 py-1 font-mono">{(q.rst_sent ?? '')}/{(q.rst_rcvd ?? '')}</td>
|
|
||||||
<td className="px-2 py-1 truncate max-w-[360px]">{q.comment}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -379,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}
|
||||||
@@ -388,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}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
|
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
@@ -32,7 +32,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
|
|||||||
|
|
||||||
type Confirmation = {
|
type Confirmation = {
|
||||||
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
||||||
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
|
new_dxcc: boolean; new_band: boolean; new_mode: boolean; new_slot: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SERVICES = [
|
const SERVICES = [
|
||||||
@@ -208,18 +208,27 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
|
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
|
||||||
|
|
||||||
|
// Worked/confirmed tallies. Slots are counted by mode CLASS (PH/CW/DIGI) — the
|
||||||
|
// raw-mode granularity (RTTY ≠ FT8) stays only in the cluster/matrix new-slot flag.
|
||||||
|
const [stats, setStats] = useState<any>(
|
||||||
|
{ slots_worked: 0, slots_confirmed: 0, dxcc_worked: 0, dxcc_confirmed: 0, ph_worked: 0, ph_confirmed: 0, cw_worked: 0, cw_confirmed: 0, dig_worked: 0, dig_confirmed: 0 });
|
||||||
|
const refreshCounts = useCallback(() => { GetSlotStats().then((c: any) => setStats(c)).catch(() => {}); }, []);
|
||||||
|
useEffect(() => { refreshCounts(); }, [refreshCounts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
|
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
|
||||||
const offDone = EventsOn('qslmgr:done', (d: any) => {
|
const offDone = EventsOn('qslmgr:done', (d: any) => {
|
||||||
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} —`]);
|
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} —`]);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
refreshCounts(); // a download may have added confirmations
|
||||||
});
|
});
|
||||||
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
|
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
|
||||||
setConfirmations((list ?? []) as Confirmation[]);
|
setConfirmations((list ?? []) as Confirmation[]);
|
||||||
setViewMode('confirmations');
|
setViewMode('confirmations');
|
||||||
|
refreshCounts();
|
||||||
});
|
});
|
||||||
return () => { offLog(); offDone(); offConf(); };
|
return () => { offLog(); offDone(); offConf(); };
|
||||||
}, []);
|
}, [refreshCounts]);
|
||||||
|
|
||||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||||
|
|
||||||
@@ -231,9 +240,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
|
|
||||||
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
||||||
switch (confFilter) {
|
switch (confFilter) {
|
||||||
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
|
case 'new': return c.new_dxcc || c.new_band || c.new_mode || c.new_slot;
|
||||||
case 'dxcc': return c.new_dxcc;
|
case 'dxcc': return c.new_dxcc;
|
||||||
case 'band': return c.new_band;
|
case 'band': return c.new_band;
|
||||||
|
case 'mode': return c.new_mode;
|
||||||
case 'slot': return c.new_slot;
|
case 'slot': return c.new_slot;
|
||||||
default: return true;
|
default: return true;
|
||||||
}
|
}
|
||||||
@@ -353,6 +363,22 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
{/* Worked / confirmed tallies. Slots by mode class (PH/CW/DIGI), with the
|
||||||
|
per-class breakdown so the totals are checkable. */}
|
||||||
|
<div className="self-center flex items-center gap-3 text-[11px] font-mono" title={t('qslm.countsTip')}>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">{t('qslm.slots')}</span>
|
||||||
|
<span className="font-semibold">{stats.slots_worked}</span>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<span className="text-success font-semibold">{stats.slots_confirmed}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="text-muted-foreground uppercase text-[9px] tracking-wider">DXCC</span>
|
||||||
|
<span className="font-semibold">{stats.dxcc_worked}</span>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<span className="text-success font-semibold">{stats.dxcc_confirmed}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{service === 'pota' && potaRes && (
|
{service === 'pota' && potaRes && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{t('qslm.potaSummaryShort', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched })}{potaRes.skipped_other_call > 0 ? t('qslm.potaOtherCall', { n: potaRes.skipped_other_call }) : ''} / {potaRes.fetched}
|
{t('qslm.potaSummaryShort', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched })}{potaRes.skipped_other_call > 0 ? t('qslm.potaOtherCall', { n: potaRes.skipped_other_call }) : ''} / {potaRes.fetched}
|
||||||
@@ -371,6 +397,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
|
<SelectItem value="new">{t('qslm.filterNew')}</SelectItem>
|
||||||
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
|
<SelectItem value="dxcc">{t('qslm.filterNewDxcc')}</SelectItem>
|
||||||
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
|
<SelectItem value="band">{t('qslm.filterNewBand')}</SelectItem>
|
||||||
|
<SelectItem value="mode">{t('qslm.filterNewMode')}</SelectItem>
|
||||||
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
|
<SelectItem value="slot">{t('qslm.filterNewSlot')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -491,6 +518,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<td className="py-1 px-2">
|
<td className="py-1 px-2">
|
||||||
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-danger-muted text-danger-muted-foreground border border-danger-border">{t('qslm.newDxcc')}</span>
|
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-danger-muted text-danger-muted-foreground border border-danger-border">{t('qslm.newDxcc')}</span>
|
||||||
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border">{t('qslm.newBand')}</span>
|
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border">{t('qslm.newBand')}</span>
|
||||||
|
: c.new_mode ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-info-muted text-info-muted-foreground border border-info-border">{t('qslm.newMode')}</span>
|
||||||
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-caution-muted text-caution-muted-foreground border border-caution-border">{t('qslm.newSlot')}</span>
|
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-caution-muted text-caution-muted-foreground border border-caution-border">{t('qslm.newSlot')}</span>
|
||||||
: <span className="text-muted-foreground/50">—</span>}
|
: <span className="text-muted-foreground/50">—</span>}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Trash2, Search, Loader2 } from 'lucide-react';
|
import { Trash2, Search, Loader2 } from 'lucide-react';
|
||||||
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
|
||||||
|
import { rstOptions, type RSTLists } from '@/lib/rst';
|
||||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||||
@@ -80,11 +81,23 @@ function StatusCell({ value }: { value?: string }) {
|
|||||||
if (v === '') {
|
if (v === '') {
|
||||||
return <span className="block text-center text-[11px] text-muted-foreground">—</span>;
|
return <span className="block text-center text-[11px] text-muted-foreground">—</span>;
|
||||||
}
|
}
|
||||||
|
// One colour per state, and each colour means something:
|
||||||
|
// Yes green — confirmed, the thing you wanted
|
||||||
|
// Requested blue — in flight, waiting on the other end. Not a problem.
|
||||||
|
// Modified orange — uploaded, then the QSO changed: it needs re-uploading.
|
||||||
|
// This is the ONLY state asking for action, so it gets the
|
||||||
|
// only alarming colour.
|
||||||
|
// No neutral — nothing done yet. Every freshly logged QSO is "No" on
|
||||||
|
// every row; painting that orange (as it used to be, in the
|
||||||
|
// same orange as Requested) made the table shout about a
|
||||||
|
// non-problem and told you nothing apart.
|
||||||
|
// Ignore dashed — deliberately excluded, on purpose.
|
||||||
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
|
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
|
||||||
const cls = v === 'Y' ? 'bg-success text-success-foreground'
|
const cls = v === 'Y' ? 'bg-success text-success-foreground border border-success'
|
||||||
: v === 'R' ? 'bg-warning text-warning-foreground'
|
: v === 'R' ? 'bg-info-muted text-info-muted-foreground border border-info-border'
|
||||||
: v === 'I' ? 'bg-muted-foreground text-background'
|
: v === 'M' ? 'bg-warning text-warning-foreground border border-warning'
|
||||||
: 'bg-warning text-warning-foreground';
|
: v === 'I' ? 'bg-muted text-muted-foreground border border-dashed border-border italic'
|
||||||
|
: 'bg-muted text-muted-foreground border border-border';
|
||||||
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
|
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +176,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
return base;
|
return base;
|
||||||
}, [modes, qso.mode]);
|
}, [modes, qso.mode]);
|
||||||
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
||||||
|
// Per-mode RST dropdown choices, loaded from the same settings as the entry form.
|
||||||
|
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
|
||||||
|
useEffect(() => {
|
||||||
|
GetListsSettings()
|
||||||
|
.then((l: any) => setRstLists({ phone: l?.rst_phone ?? [], cw: l?.rst_cw ?? [], digital: l?.rst_digital ?? [] }))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
// Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
|
// Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
|
||||||
const splitHz = (hz?: number) => hz
|
const splitHz = (hz?: number) => hz
|
||||||
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
||||||
@@ -351,7 +371,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
<DialogContent className="max-w-4xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||||
<DialogHeader className="flex-row items-baseline gap-2">
|
<DialogHeader className="flex-row items-baseline gap-2">
|
||||||
<DialogTitle>{t('qedit.title')}</DialogTitle>
|
<DialogTitle>{t('qedit.title')}</DialogTitle>
|
||||||
<span className="font-mono text-xs text-muted-foreground">#{draft.id} — {draft.callsign}</span>
|
<span className="font-mono text-xs text-muted-foreground">#{draft.id} — {draft.callsign}</span>
|
||||||
@@ -392,9 +412,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-20"><Label>S</Label>
|
<div className="flex flex-col w-20"><Label>S</Label>
|
||||||
<Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} className="font-mono" /></div>
|
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_sent', v)} /></div>
|
||||||
<div className="flex flex-col w-20"><Label>R</Label>
|
<div className="flex flex-col w-20"><Label>R</Label>
|
||||||
<Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} className="font-mono" /></div>
|
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
|
||||||
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||||||
title={t('qedit.fetchTitle')}>
|
title={t('qedit.fetchTitle')}>
|
||||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||||
@@ -571,9 +591,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: live status grid for every channel */}
|
{/* Right: live status grid for every channel.
|
||||||
<div className="w-72 shrink-0">
|
Sized by its content, not pinned to a width: a fixed 288px box
|
||||||
<table className="w-full border-separate" style={{ borderSpacing: 4 }}>
|
left the label column too narrow, so "QSL (paper)" wrapped onto
|
||||||
|
two lines and padded out the whole row. There is spare width to
|
||||||
|
the right — spend it on the label. */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<table className="border-separate" style={{ borderSpacing: 4 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
<th className="text-left font-semibold">{t('qedit.thType')}</th>
|
<th className="text-left font-semibold">{t('qedit.thType')}</th>
|
||||||
@@ -584,7 +608,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<tbody>
|
<tbody>
|
||||||
{CONFIRMATIONS.map((c) => (
|
{CONFIRMATIONS.map((c) => (
|
||||||
<tr key={c.key} className="text-xs">
|
<tr key={c.key} className="text-xs">
|
||||||
<td className="font-medium pr-2 py-0.5">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
|
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
|
||||||
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
||||||
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -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,7 +30,20 @@ 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
|
||||||
|
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
||||||
|
// reuse this grid elsewhere with its OWN independent column config.
|
||||||
|
storageKey?: string;
|
||||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||||
|
onRowClicked?: (q: QSOForm) => void;
|
||||||
onRowSelected?: (ids: number[]) => void;
|
onRowSelected?: (ids: number[]) => void;
|
||||||
onUpdateFromCty?: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
@@ -44,12 +57,16 @@ type Props = {
|
|||||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onExportCabrilloFiltered?: () => void;
|
onExportCabrilloFiltered?: () => void;
|
||||||
onDelete?: (ids: number[]) => void;
|
onDelete?: (ids: number[]) => void;
|
||||||
|
// Reports how many rows the grid shows after its COLUMN filters (the funnel
|
||||||
|
// icons), or null when no column filter is active — so the parent's "Showing X
|
||||||
|
// of Y" can reflect them. Fired on filter change and when the data updates.
|
||||||
|
onFilteredCountChange?: (count: number | null) => void;
|
||||||
// One column per defined award; the cell shows the reference this QSO counts
|
// One column per defined award; the cell shows the reference this QSO counts
|
||||||
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||||
awardCols?: { code: string; name: string }[];
|
awardCols?: { code: string; name: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
|
const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
|
||||||
|
|
||||||
function fmtMhzDots(hz?: number): string {
|
function fmtMhzDots(hz?: number): string {
|
||||||
if (!hz) return '';
|
if (!hz) return '';
|
||||||
@@ -203,6 +220,13 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
{ group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 },
|
{ group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 },
|
||||||
{ group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 },
|
{ group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 },
|
||||||
{ group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 },
|
{ group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_name'), colId: 'my_name', headerName: t('rqg.c.my_name'), field: 'my_name' as any, width: 120 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_wwff'), colId: 'my_wwff_ref', headerName: t('rqg.c.my_wwff'), field: 'my_wwff_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_sig'), colId: 'my_sig', headerName: t('rqg.c.my_sig'), field: 'my_sig' as any, width: 90 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_sig_info'), colId: 'my_sig_info', headerName: t('rqg.c.my_sig_info'), field: 'my_sig_info' as any, width: 120 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_arrl_sect'), colId: 'my_arrl_sect', headerName: t('rqg.c.my_arrl_sect'), field: 'my_arrl_sect' as any, width: 90, cellClass: 'font-mono' },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_darc_dok'), colId: 'my_darc_dok', headerName: t('rqg.c.my_darc_dok'), field: 'my_darc_dok' as any, width: 90, cellClass: 'font-mono' },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_vucc_grids'),colId: 'my_vucc_grids', headerName: t('rqg.c.my_vucc_grids'), field: 'my_vucc_grids' as any, width: 130, cellClass: 'font-mono' },
|
||||||
|
|
||||||
// ── Misc ──
|
// ── Misc ──
|
||||||
{ group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
{ group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
||||||
@@ -226,11 +250,23 @@ const GRP_KEYS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
|
// Award columns are governed SOLELY by the awardShown code-set, never by AG
|
||||||
|
// Grid's saved column state. Stripping them here (on both save and restore)
|
||||||
|
// stops a stale saved state from re-hiding a shown award column on every
|
||||||
|
// awardCols rebuild — the desync that made award columns vanish mid-session.
|
||||||
|
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||||
|
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||||
|
|
||||||
|
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);
|
||||||
|
// Column-layout persistence keys — scoped by storageKey so a reused instance
|
||||||
|
// (e.g. the Net panel) keeps its own layout independent of the main log.
|
||||||
|
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]);
|
||||||
@@ -262,11 +298,39 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
// auto-save during that window. Set in the memo (runs at render, before the
|
// auto-save during that window. Set in the memo (runs at render, before the
|
||||||
// column events) and cleared by the re-apply effect below.
|
// column events) and cleared by the re-apply effect below.
|
||||||
const restoringRef = useRef(true);
|
const restoringRef = useRef(true);
|
||||||
|
|
||||||
|
// Award-column visibility is stored as an explicit set of award CODES, NOT via
|
||||||
|
// AG Grid's column-state round-trip. Those columns load asynchronously and
|
||||||
|
// race the state restore, and AG Grid preserves an existing column's visibility
|
||||||
|
// across a columnDefs rebuild instead of re-reading `hide` — which is exactly
|
||||||
|
// why previously-shown award columns kept vanishing on reopen. A dedicated set
|
||||||
|
// is deterministic: it drives `hide` directly and is re-enforced below.
|
||||||
|
const AWARD_SHOWN_KEY = storageKey ? `hamlog.awardColsShown.${storageKey}` : 'hamlog.awardColsShown';
|
||||||
|
const [awardShown, setAwardShown] = useState<Set<string>>(() => new Set((loadLocal(AWARD_SHOWN_KEY) ?? []).map((s) => String(s).toUpperCase())));
|
||||||
|
useEffect(() => {
|
||||||
|
// Fresh machine: hydrate the set from the portable DB copy, then seed the cache.
|
||||||
|
if (loadLocal(AWARD_SHOWN_KEY)) return;
|
||||||
|
loadRemote(AWARD_SHOWN_KEY).then((remote) => {
|
||||||
|
if (remote && remote.length) {
|
||||||
|
seedLocal(AWARD_SHOWN_KEY, remote);
|
||||||
|
setAwardShown(new Set(remote.map((s: any) => String(s).toUpperCase())));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const persistAwardShown = useCallback((next: Set<string>) => {
|
||||||
|
setAwardShown(next);
|
||||||
|
saveState(AWARD_SHOWN_KEY, [...next]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
||||||
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}`,
|
||||||
@@ -274,10 +338,26 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
||||||
width: 110,
|
width: 110,
|
||||||
cellClass: 'text-[11px]',
|
cellClass: 'text-[11px]',
|
||||||
|
// Visibility comes from the persisted award-code set, so a column the user
|
||||||
|
// showed reappears on reopen and one they didn't stays hidden.
|
||||||
|
hide: !awardShown.has(a.code.toUpperCase()),
|
||||||
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]);
|
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall]);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
// so we set it explicitly whenever the award set or the loaded columns change.
|
||||||
|
useEffect(() => {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api || !awardCols?.length) return;
|
||||||
|
for (const a of awardCols) {
|
||||||
|
const want = awardShown.has(a.code.toUpperCase());
|
||||||
|
const col = api.getColumn(`award_${a.code}`);
|
||||||
|
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
|
||||||
|
}
|
||||||
|
}, [awardCols, awardShown]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true,
|
sortable: true,
|
||||||
@@ -287,42 +367,56 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
function onGridReady(e: GridReadyEvent) {
|
||||||
const local = loadLocal(COL_STATE_KEY);
|
onGridApi?.(e.api);
|
||||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
const local = loadLocal(colStateKey);
|
||||||
|
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
|
||||||
// (fresh machine / after a reinstall), then re-seed the cache.
|
// (fresh machine / after a reinstall), then re-seed the cache.
|
||||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
loadRemote(colStateKey).then((remote) => {
|
||||||
if (remote && !local) {
|
if (remote && !local) {
|
||||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true });
|
||||||
seedLocal(COL_STATE_KEY, remote);
|
seedLocal(colStateKey, remote);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Report the post-column-filter row count (funnel filters) to the parent, or
|
||||||
|
// null when no column filter is active, so "Showing X of Y" reflects them.
|
||||||
|
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
||||||
|
const api = e?.api ?? gridRef.current?.api;
|
||||||
|
if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
|
||||||
|
if (!api || !onFilteredCountChange) return;
|
||||||
|
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
||||||
|
}, [onFilteredCountChange]);
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
if (state) saveState(COL_STATE_KEY, state);
|
if (state) saveState(colStateKey, stripAwardCols(state));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// The award columns load asynchronously; when they arrive (or change) the
|
// columnDefs is rebuilt whenever the award columns load OR the user toggles an
|
||||||
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide`
|
// award column (both change the memo → restoringRef flips true at line 316). Each
|
||||||
// default — wiping the user's saved visibility (award columns reappear,
|
// rebuild makes AG Grid re-apply every colDef's `hide` default, wiping the user's
|
||||||
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after
|
// saved visibility of the NON-award columns (QTH/Grid reappear, a manually-shown
|
||||||
// every rebuild so the user's choices win. No-op before the grid is ready.
|
// LoTW-sent vanishes). Re-apply the saved (award-stripped) state after EVERY such
|
||||||
|
// rebuild — hence awardShown in the deps, not just awardCols; without it, toggling
|
||||||
|
// an award reset the other columns AND left restoringRef stuck true (saving off).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
const local = loadLocal(COL_STATE_KEY);
|
const local = loadLocal(colStateKey);
|
||||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
if (api && local) api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
||||||
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [awardCols]);
|
}, [awardCols, awardShown]);
|
||||||
|
|
||||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
}
|
}
|
||||||
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).
|
||||||
@@ -331,15 +425,42 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
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
|
||||||
// source of truth, and saveColumnState persists it.
|
// source of truth, and saveColumnState persists it.
|
||||||
function isColVisible(colId: string): boolean {
|
function isColVisible(colId: string): boolean {
|
||||||
|
// Award columns: the persisted set is the source of truth (see awardShown).
|
||||||
|
if (colId.startsWith('award_')) return awardShown.has(colId.slice('award_'.length).toUpperCase());
|
||||||
const col = gridRef.current?.api?.getColumn(colId);
|
const col = gridRef.current?.api?.getColumn(colId);
|
||||||
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
||||||
}
|
}
|
||||||
function setColVisible(colId: string, visible: boolean) {
|
function setColVisible(colId: string, visible: boolean) {
|
||||||
|
// Award columns are driven by the persisted code set — update it and let the
|
||||||
|
// enforce effect apply visibility. This is what makes them stick across reopen.
|
||||||
|
if (colId.startsWith('award_')) {
|
||||||
|
const code = colId.slice('award_'.length).toUpperCase();
|
||||||
|
const next = new Set(awardShown);
|
||||||
|
if (visible) next.add(code); else next.delete(code);
|
||||||
|
persistAwardShown(next);
|
||||||
|
gridRef.current?.api?.setColumnsVisible([colId], visible);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
api.setColumnsVisible([colId], visible);
|
api.setColumnsVisible([colId], visible);
|
||||||
@@ -367,11 +488,40 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
api.setColumnsVisible(visible, true);
|
api.setColumnsVisible(visible, true);
|
||||||
api.setColumnsVisible(hidden, false);
|
api.setColumnsVisible(hidden, false);
|
||||||
saveColumnState();
|
saveColumnState();
|
||||||
|
// Award columns are opt-in: reset hides them all.
|
||||||
|
persistAwardShown(new Set());
|
||||||
|
(awardCols ?? []).forEach((a) => api.setColumnsVisible([`award_${a.code}`], false));
|
||||||
}
|
}
|
||||||
|
|
||||||
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')}
|
||||||
@@ -390,12 +540,15 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||||
onGridReady={onGridReady}
|
onGridReady={onGridReady}
|
||||||
|
onFilterChanged={reportFilteredCount}
|
||||||
|
onModelUpdated={reportFilteredCount}
|
||||||
onColumnResized={saveColumnState}
|
onColumnResized={saveColumnState}
|
||||||
onColumnMoved={saveColumnState}
|
onColumnMoved={saveColumnState}
|
||||||
onColumnPinned={saveColumnState}
|
onColumnPinned={saveColumnState}
|
||||||
onColumnVisible={saveColumnState}
|
onColumnVisible={saveColumnState}
|
||||||
onSortChanged={saveColumnState}
|
onSortChanged={saveColumnState}
|
||||||
onRowDoubleClicked={handleRowDoubleClicked}
|
onRowDoubleClicked={handleRowDoubleClicked}
|
||||||
|
onRowClicked={(e) => e.data && onRowClicked?.(e.data)}
|
||||||
onSelectionChanged={onSelectionChanged}
|
onSelectionChanged={onSelectionChanged}
|
||||||
onCellContextMenu={onCellContextMenu}
|
onCellContextMenu={onCellContextMenu}
|
||||||
preventDefaultOnContextMenu
|
preventDefaultOnContextMenu
|
||||||
@@ -463,8 +616,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('rqg.grpAwards')}</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('rqg.grpAwards')}</span>
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>{t('rqg.all')}</button>
|
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { persistAwardShown(new Set(awardCols.map((a) => a.code.toUpperCase()))); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], true)); }}>{t('rqg.all')}</button>
|
||||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>{t('rqg.none')}</button>
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { persistAwardShown(new Set()); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], false)); }}>{t('rqg.none')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,768 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, Flame } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
|
import {
|
||||||
|
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||||
|
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||||
|
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||||
|
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||||
|
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, GetFlexState, FlexAmpOperate,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
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; channels?: number; labels: string[] };
|
||||||
|
type Relay = { number: number; label: string; on: boolean };
|
||||||
|
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||||||
|
|
||||||
|
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', 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 {
|
||||||
|
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||||||
|
}
|
||||||
|
|
||||||
|
type Heading = { enabled: boolean; ok: boolean; azimuth: number };
|
||||||
|
|
||||||
|
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
|
||||||
|
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
||||||
|
// GoTo box, quick N/E/S/W presets, and Stop. Heading is polled by the panel and
|
||||||
|
// passed in (so the panel can also order this widget); it drives the shared
|
||||||
|
// rotator backend.
|
||||||
|
function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: RotatorProps & {
|
||||||
|
hd: Heading; refetch: () => void; t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const [goto, setGoto] = useState('');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
const turn = (az: number) => {
|
||||||
|
const a = ((Math.round(az) % 360) + 360) % 360;
|
||||||
|
RotatorGoTo(a, -1).then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||||||
|
};
|
||||||
|
const poll = refetch;
|
||||||
|
|
||||||
|
const presets: [string, number][] = [['N', 0], ['E', 90], ['S', 180], ['W', 270]];
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Compass className="size-4 text-primary" />
|
||||||
|
<div className="text-sm font-semibold">{t('station.rotator')}</div>
|
||||||
|
<span className={cn('ml-auto size-2 rounded-full', hd.ok ? 'bg-success' : 'bg-warning')}
|
||||||
|
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
||||||
|
</div>
|
||||||
|
<div className="p-3 flex gap-4 items-start">
|
||||||
|
<RotorCompass
|
||||||
|
bearing={bearing ?? null}
|
||||||
|
headings={hd.ok ? [hd.azimuth] : []}
|
||||||
|
centerLat={centerLat ?? null}
|
||||||
|
centerLon={centerLon ?? null}
|
||||||
|
rotorEnabled={hd.ok}
|
||||||
|
onGoto={(az) => turn(az)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
|
<div className="font-mono">
|
||||||
|
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{presets.map(([lbl, az]) => (
|
||||||
|
<button key={lbl} type="button" onClick={() => turn(az)}
|
||||||
|
className="flex-1 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:bg-muted">
|
||||||
|
{lbl}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Input value={goto} onChange={(e) => setGoto(e.target.value.replace(/[^0-9]/g, ''))}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && goto) turn(parseInt(goto, 10)); }}
|
||||||
|
placeholder="0–359" className="h-8 flex-1 min-w-0 font-mono text-sm" />
|
||||||
|
<Button size="sm" className="h-8 shrink-0" disabled={!goto} onClick={() => turn(parseInt(goto, 10))}>{t('station.go')}</Button>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" className="h-8 w-full" onClick={() => RotatorStop().then(poll).catch((e) => setErr(String(e?.message ?? e)))}>
|
||||||
|
<Square className="size-3 mr-1" /> {t('station.stop')}
|
||||||
|
</Button>
|
||||||
|
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[] };
|
||||||
|
|
||||||
|
// MotorAntennaWidget controls a motorized antenna (Ultrabeam / SteppIR) from the
|
||||||
|
// Station Control tab: pattern (Normal / 180° / Bi), Retract, and — Ultrabeam
|
||||||
|
// only — per-element length adjustment. Heading/state is polled by the panel.
|
||||||
|
const ELEMENT_STEP = 2; // the physical console adjusts 2 mm per press
|
||||||
|
|
||||||
|
// elementName maps an element index to a ham-radio name: 0 = reflector,
|
||||||
|
// 1 = driven element, then Director 1, 2, 3…
|
||||||
|
function elementName(i: number, t: (k: string, v?: any) => string): string {
|
||||||
|
if (i === 0) return t('station.reflector');
|
||||||
|
if (i === 1) return t('station.driven');
|
||||||
|
return `${t('station.director')} ${i - 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () => void; t: (k: string, v?: any) => string }) {
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [lengths, setLengths] = useState<number[]>([]); // current element lengths (mm), from ReadElements
|
||||||
|
const [reading, setReading] = useState(false);
|
||||||
|
const [busyEl, setBusyEl] = useState<number | null>(null);
|
||||||
|
const [editingEl, setEditingEl] = useState<number | null>(null); // element whose mm is being typed
|
||||||
|
const [editVal, setEditVal] = useState('');
|
||||||
|
const run = (p: Promise<any>) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||||||
|
const isUB = ant.type !== 'steppir';
|
||||||
|
|
||||||
|
const readLengths = useCallback(async () => {
|
||||||
|
setReading(true); setErr('');
|
||||||
|
try { setLengths(((await MotorReadElements()) ?? []) as number[]); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
finally { setReading(false); }
|
||||||
|
}, []);
|
||||||
|
// Read the current lengths once when the Ultrabeam widget mounts/connects, so
|
||||||
|
// +/- starts from the real values rather than blind.
|
||||||
|
useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]);
|
||||||
|
|
||||||
|
// Send an absolute length to one element and remember it as the new baseline.
|
||||||
|
const setLen = async (i: number, mm: number) => {
|
||||||
|
const next = Math.max(0, Math.round(mm));
|
||||||
|
const prev = lengths[i] ?? 0;
|
||||||
|
setBusyEl(i); setErr('');
|
||||||
|
setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic
|
||||||
|
try { await MotorSetElement(i, next); refetch(); }
|
||||||
|
catch (e: any) {
|
||||||
|
// Rejected by the controller — most often the element is at its travel
|
||||||
|
// limit for this band (extending on a low band). Revert the optimistic
|
||||||
|
// value so the display stays truthful, and explain the likely cause when
|
||||||
|
// the failed move was an extension.
|
||||||
|
setLengths((ls) => { const c = [...ls]; c[i] = prev; return c; });
|
||||||
|
setErr(next > prev ? t('station.atMax') : String(e?.message ?? e));
|
||||||
|
}
|
||||||
|
finally { setBusyEl(null); }
|
||||||
|
};
|
||||||
|
// Nudge one element by ±2 mm from its current known length.
|
||||||
|
const nudge = (i: number, delta: number) => setLen(i, (lengths[i] ?? 0) + delta);
|
||||||
|
// Commit a typed exact length (click on the mm value). Lets the operator fix
|
||||||
|
// the baseline when the auto-read is off, so +/- then work reliably.
|
||||||
|
const commitEdit = (i: number) => {
|
||||||
|
const v = parseInt(editVal, 10);
|
||||||
|
setEditingEl(null);
|
||||||
|
if (!isNaN(v) && v >= 0 && v !== lengths[i]) setLen(i, v);
|
||||||
|
};
|
||||||
|
const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]];
|
||||||
|
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">
|
||||||
|
<AntennaIcon className="size-4 text-primary" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold truncate">{isUB ? 'Ultrabeam' : 'SteppIR'}</div>
|
||||||
|
{ant.frequency > 0 && <div className="text-[10px] text-muted-foreground font-mono">{(ant.frequency / 1000).toFixed(3)} MHz</div>}
|
||||||
|
</div>
|
||||||
|
{ant.moving && <span className="ml-auto text-[10px] font-semibold text-warning animate-pulse">{t('station.moving')}</span>}
|
||||||
|
<span className={cn('size-2 rounded-full shrink-0', ant.moving ? '' : 'ml-auto', ant.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||||
|
title={ant.connected ? t('station.online') : t('station.offline')} />
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.pattern')}</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{dirs.map(([d, lbl]) => (
|
||||||
|
<button key={d} type="button" disabled={!ant.connected}
|
||||||
|
onClick={() => run(SetUltrabeamDirection(d))}
|
||||||
|
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors disabled:opacity-40',
|
||||||
|
ant.direction === d ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
|
||||||
|
{lbl}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" disabled={!ant.connected}
|
||||||
|
onClick={() => run(UltrabeamRetract())}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1.5 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||||
|
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isUB && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('station.elements')}</span>
|
||||||
|
<button type="button" onClick={readLengths} disabled={!ant.connected || reading}
|
||||||
|
className="text-[10px] text-primary hover:underline inline-flex items-center gap-1 disabled:opacity-40" title={t('station.readLengths')}>
|
||||||
|
<RefreshCw className={cn('size-3', reading && 'animate-spin')} /> {t('station.read')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{lengths.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{/* Hide 0-length elements — an Ultrabeam reports 6 slots but a
|
||||||
|
3-element beam only uses the first few. */}
|
||||||
|
{lengths.map((mm, i) => ({ mm, i })).filter((e) => e.mm > 0).map(({ mm, i }) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
|
||||||
|
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||||
|
onClick={() => nudge(i, -ELEMENT_STEP)}
|
||||||
|
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||||
|
<Minus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
{editingEl === i ? (
|
||||||
|
<input autoFocus type="number" value={editVal}
|
||||||
|
onChange={(e) => setEditVal(e.target.value)}
|
||||||
|
onBlur={() => commitEdit(i)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(i); if (e.key === 'Escape') setEditingEl(null); }}
|
||||||
|
className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums bg-transparent border border-primary rounded px-1" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums cursor-pointer hover:underline"
|
||||||
|
title={t('station.setExactLen')}
|
||||||
|
onClick={() => { setEditingEl(i); setEditVal(String(mm)); }}>
|
||||||
|
{busyEl === i ? <Loader2 className="size-3.5 animate-spin inline" /> : `${mm} mm`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||||
|
onClick={() => nudge(i, ELEMENT_STEP)}
|
||||||
|
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">{t('station.elementsHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
|
const [status, setStatus] = useState<Record<string, DevStatus>>({});
|
||||||
|
const [editing, setEditing] = useState<Device | null>(null); // device being added/edited
|
||||||
|
const [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight
|
||||||
|
const [rot, setRot] = useState<Heading>({ enabled: false, ok: false, azimuth: 0 });
|
||||||
|
const [ant, setAnt] = useState<AntStatus>({ enabled: false, type: '', connected: false, direction: 0, frequency: 0, moving: false, elements: [] });
|
||||||
|
// Widget order (rotator + device ids), drag-and-drop reorderable, persisted.
|
||||||
|
const [order, setOrder] = useState<string[]>(() => {
|
||||||
|
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
|
||||||
|
});
|
||||||
|
const dragId = useRef<string | null>(null);
|
||||||
|
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 () => {
|
||||||
|
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const poll = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const s = ((await GetStationStatus()) ?? []) as DevStatus[];
|
||||||
|
setStatus(Object.fromEntries(s.map((d) => [d.id, d])));
|
||||||
|
} catch { /* ignore transient */ }
|
||||||
|
}, []);
|
||||||
|
const pollRot = useCallback(async () => {
|
||||||
|
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
const pollAnt = useCallback(async () => {
|
||||||
|
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadDevices(); }, [loadDevices]);
|
||||||
|
useEffect(() => {
|
||||||
|
poll(); pollRot(); pollAnt();
|
||||||
|
const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [poll, pollRot, pollAnt, devices.length]);
|
||||||
|
|
||||||
|
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
|
||||||
|
// Reorder so `dragged` lands just before `target`.
|
||||||
|
const onDrop = (targetId: string) => {
|
||||||
|
const src = dragId.current; dragId.current = null;
|
||||||
|
if (!src || src === targetId) return;
|
||||||
|
const ids = widgetIds.filter((id) => id !== src);
|
||||||
|
const at = ids.indexOf(targetId);
|
||||||
|
ids.splice(at < 0 ? ids.length : at, 0, src);
|
||||||
|
persistOrder(ids);
|
||||||
|
};
|
||||||
|
|
||||||
|
const persist = async (next: Device[]) => {
|
||||||
|
setDevices(next);
|
||||||
|
try { await SaveStationDevices(next as any); } catch { /* surfaced by status */ }
|
||||||
|
await loadDevices();
|
||||||
|
poll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = async (dev: Device, relay: number, on: boolean) => {
|
||||||
|
const key = `${dev.id}:${relay}`;
|
||||||
|
setBusy((b) => ({ ...b, [key]: true }));
|
||||||
|
// Optimistic flip so the switch feels instant; the poll reconciles.
|
||||||
|
setStatus((st) => {
|
||||||
|
const d = st[dev.id]; if (!d) return st;
|
||||||
|
return { ...st, [dev.id]: { ...d, relays: d.relays.map((r) => (r.number === relay ? { ...r, on } : r)) } };
|
||||||
|
});
|
||||||
|
try { await StationSetRelay(dev.id, relay, on); } catch { /* poll will correct */ }
|
||||||
|
await poll();
|
||||||
|
setBusy((b) => ({ ...b, [key]: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
if (!editing) return;
|
||||||
|
const d = { ...editing, name: editing.name.trim() || TYPE_LABEL[editing.type], host: editing.host.trim() };
|
||||||
|
const exists = devices.some((x) => x.id && x.id === d.id);
|
||||||
|
await persist(exists ? devices.map((x) => (x.id === d.id ? d : x)) : [...devices, d]);
|
||||||
|
setEditing(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDevice = async (id: string) => { await persist(devices.filter((x) => x.id !== id)); };
|
||||||
|
|
||||||
|
// Build the widget list (rotator first by default, then devices), then order it
|
||||||
|
// by the saved drag order — unknown ids fall to the end in their natural order.
|
||||||
|
const deviceCard = (dev: Device) => {
|
||||||
|
const st = status[dev.id];
|
||||||
|
const relays = st?.relays ?? dev.labels.map((label, i) => ({ number: i + 1, label, on: false }));
|
||||||
|
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">
|
||||||
|
<PlugZap className="size-4 text-primary" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold truncate">{dev.name || TYPE_LABEL[dev.type]}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground font-mono truncate">{TYPE_LABEL[dev.type]} · {dev.host}</div>
|
||||||
|
</div>
|
||||||
|
<span className={cn('ml-auto size-2 rounded-full shrink-0', st?.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||||
|
title={st?.connected ? t('station.online') : (st?.error || t('station.offline'))} />
|
||||||
|
<button className="text-muted-foreground hover:text-foreground" title={t('station.edit')}
|
||||||
|
onClick={() => setEditing({ ...dev, labels: [...dev.labels] })}><Pencil className="size-3.5" /></button>
|
||||||
|
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
|
||||||
|
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 grid grid-cols-2 gap-2">
|
||||||
|
{relays.map((r) => {
|
||||||
|
const key = `${dev.id}:${r.number}`;
|
||||||
|
const label = r.label || `${t('station.relay')} ${r.number}`;
|
||||||
|
return (
|
||||||
|
<button key={r.number} type="button" disabled={!st?.connected}
|
||||||
|
onClick={() => toggle(dev, r.number, !r.on)}
|
||||||
|
className={cn('flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors disabled:opacity-40',
|
||||||
|
r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||||||
|
<span className={cn('flex items-center justify-center size-7 rounded-md shrink-0',
|
||||||
|
r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||||
|
{busy[key] ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-xs font-medium truncate">{label}</span>
|
||||||
|
<span className={cn('block text-[10px] font-semibold', r.on ? 'text-success' : 'text-muted-foreground')}>
|
||||||
|
{r.on ? t('station.on') : t('station.off')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const widgets: { id: string; node: React.ReactNode }[] = [];
|
||||||
|
if (rot.enabled) {
|
||||||
|
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||||
|
}
|
||||||
|
if (ant.enabled) {
|
||||||
|
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) });
|
||||||
|
|
||||||
|
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||||||
|
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
||||||
|
const widgetIds = ordered.map((w) => w.id);
|
||||||
|
|
||||||
|
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && ampCount === 0;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// fill however many columns are available.
|
||||||
|
const gridCols: Record<string, string> = {
|
||||||
|
auto: 'sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4',
|
||||||
|
'1': 'grid-cols-1', '2': 'grid-cols-2', '3': 'grid-cols-3', '4': 'grid-cols-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
||||||
|
{(['auto', '2', '3', '4'] as const).map((c) => (
|
||||||
|
<button key={c} type="button"
|
||||||
|
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
||||||
|
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||||
|
{c === 'auto' ? t('station.colsAuto') : c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
|
||||||
|
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{noDevices && !editing && (
|
||||||
|
<div className="max-w-4xl rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t('station.empty')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cn('grid gap-3 items-start', gridCols[cols] ?? gridCols.auto)}>
|
||||||
|
{ordered.map((w) => (
|
||||||
|
<div key={w.id} draggable
|
||||||
|
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
||||||
|
onDrop={(e) => { e.preventDefault(); onDrop(w.id); }}
|
||||||
|
className={cn('cursor-grab active:cursor-grabbing', dragId.current === w.id && 'opacity-60')}>
|
||||||
|
{w.node}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!noDevices && (
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-2">{t('station.dragHint')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<DeviceEditor device={editing} onChange={setEditing} onSave={saveEdit} onCancel={() => setEditing(null)} t={t} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||||
|
device: Device; onChange: (d: Device) => void; onSave: () => void; onCancel: () => void; t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
|
||||||
|
const setType = (type: string) => {
|
||||||
|
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] ?? '');
|
||||||
|
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 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 (
|
||||||
|
<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="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.type')}</Label>
|
||||||
|
<Select value={device.type} onValueChange={setType}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</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>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.name')}</Label>
|
||||||
|
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||||||
|
</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('space-y-1', isKM ? '' : 'max-w-xs')}>
|
||||||
|
<Label>{t('station.host')}</Label>
|
||||||
|
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
{isKM && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.user')}</Label>
|
||||||
|
<Input value={device.user ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, user: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.pass')}</Label>
|
||||||
|
<Input type="password" value={device.pass ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, pass: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.labels')}</Label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{device.labels.map((lab, i) => (
|
||||||
|
<Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"
|
||||||
|
onChange={(e) => { const labels = [...device.labels]; labels[i] = e.target.value; onChange({ ...device, labels }); }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{testMsg && (
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-xs font-medium', testMsg.ok ? 'text-success' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', testMsg.ok ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{testMsg.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
|
||||||
|
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
|
||||||
|
{t('station.test')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
||||||
|
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,856 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-react';
|
||||||
|
import { GetLogStats, GetContestRuns, GetOperators } from '../../wailsjs/go/main/App';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Statistics dashboard.
|
||||||
|
//
|
||||||
|
// Colour rules this file obeys (they are not stylistic — they are what keeps the
|
||||||
|
// charts readable and colour-blind-safe):
|
||||||
|
//
|
||||||
|
// • A chart with ONE series uses ONE hue (--chart-1) for every bar. Colour
|
||||||
|
// encodes identity, never a magnitude the bar's length already shows.
|
||||||
|
// • The categorical slots (--chart-1..8) are used in FIXED ORDER and only where
|
||||||
|
// the categories ARE the subject (the continent donut). The order is the
|
||||||
|
// colour-blind-safety mechanism, so it is never shuffled or cycled.
|
||||||
|
// • The donut is for part-to-whole read at a glance. It is NOT used for modes:
|
||||||
|
// CW and SSB are nearly tied, and a donut hides exactly the comparison that
|
||||||
|
// makes them interesting. Close values belong in bars.
|
||||||
|
// • Every value is direct-labelled, so nothing is reachable only by hovering, and
|
||||||
|
// a table view gives the WCAG-clean twin.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 ContestRun = { id: string; year: number; count: number; start: string; end: string };
|
||||||
|
type Stats = {
|
||||||
|
total: number; unique_calls: number; entities: number; continents: number;
|
||||||
|
first_qso: string; last_qso: string;
|
||||||
|
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
||||||
|
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_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
|
||||||
|
// Period / contest metrics.
|
||||||
|
window_start: string; window_end: string; window_hours: number;
|
||||||
|
avg_per_hour: number; avg_per_active: number;
|
||||||
|
on_air_minutes: number; off_air_minutes: number;
|
||||||
|
peak_hour_key: string; peak_hour_count: number; best_60: number;
|
||||||
|
gaps: Gap[]; rate: Bucket[];
|
||||||
|
rate_ops: string[]; rate_by_op: number[][];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Minutes → "3 h 12" (a bare "192 min" makes you do arithmetic to read a break).
|
||||||
|
const dur = (m: number) => (m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')}` : `${m} min`);
|
||||||
|
|
||||||
|
const CONTINENT_NAME: Record<string, string> = {
|
||||||
|
EU: 'Europe', NA: 'North America', SA: 'South America',
|
||||||
|
AS: 'Asia', AF: 'Africa', OC: 'Oceania', AN: 'Antarctica',
|
||||||
|
};
|
||||||
|
|
||||||
|
const nf = (n: number) => n.toLocaleString('en-US');
|
||||||
|
|
||||||
|
// ── Shell ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Card({ title, sub, children, className, accent = 'var(--chart-1)' }: { title: string; sub?: string; children: React.ReactNode; className?: string; accent?: string }) {
|
||||||
|
return (
|
||||||
|
<section className={cn('rounded-xl border border-border bg-card p-3.5 flex flex-col min-w-0 shadow-sm', className)}>
|
||||||
|
<header className="mb-3 shrink-0">
|
||||||
|
<h3 className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
<span className="size-1.5 rounded-full shrink-0" style={{ background: accent }} />
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A headline number IS the chart — a one-bar bar chart would be noise. Each tile
|
||||||
|
// carries an accent (a categorical chart hue or a semantic token): a soft tint
|
||||||
|
// wash + a left accent bar give it a colourful identity, while the number itself
|
||||||
|
// stays in high-contrast foreground ink so it reads on every theme.
|
||||||
|
function StatTile({ label, value, sub, accent = 'var(--chart-1)' }: { label: string; value: string; sub?: string; accent?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden rounded-xl border border-border bg-card px-4 py-3 min-w-0 shadow-sm">
|
||||||
|
<div className="pointer-events-none absolute inset-0 opacity-[0.08]" style={{ background: accent }} />
|
||||||
|
<div className="pointer-events-none absolute -right-5 -top-7 size-20 rounded-full blur-xl opacity-[0.14]" style={{ background: accent }} />
|
||||||
|
<div className="absolute left-0 inset-y-0 w-1" style={{ background: accent }} />
|
||||||
|
<div className="relative">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
|
||||||
|
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
|
||||||
|
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
|
||||||
|
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Horizontal bars (long category names: modes, operators, entities) ─────────
|
||||||
|
// One series → one hue. The value is direct-labelled, so no reader ever depends
|
||||||
|
// on a tooltip to get a number.
|
||||||
|
|
||||||
|
function HBars({ data, max, empty, share, labelWidth = 'w-20', colorful, color = 'var(--chart-1)' }: { data: Bucket[]; max?: number; empty: string; share?: boolean; labelWidth?: string; colorful?: boolean; color?: string }) {
|
||||||
|
// max is a display cap for long tails (top entities). Where EVERY row matters —
|
||||||
|
// the operators of a multi-op — it is deliberately not set: a capped chart would
|
||||||
|
// silently drop the 9th operator, and "who worked what" is the whole question.
|
||||||
|
const top = max ? data.slice(0, max) : data;
|
||||||
|
const peak = Math.max(1, ...top.map((d) => d.count));
|
||||||
|
const total = data.reduce((s, d) => s + d.count, 0);
|
||||||
|
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 min-w-0">
|
||||||
|
{top.map((d, i) => (
|
||||||
|
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
||||||
|
<span className={`${labelWidth} shrink-0 truncate text-[11px] text-muted-foreground text-right`} title={d.key}>{d.key}</span>
|
||||||
|
<div className="flex-1 min-w-0 h-[14px] flex items-center">
|
||||||
|
<div
|
||||||
|
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
||||||
|
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: colorful ? `var(--chart-${(i % 8) + 1})` : color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
|
||||||
|
{share && (
|
||||||
|
<span className="w-10 shrink-0 text-[11px] text-muted-foreground text-right tabular-nums">
|
||||||
|
{total ? ((d.count / total) * 100).toFixed(0) : 0}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vertical columns (short ordered labels: bands in band-plan order, hours) ──
|
||||||
|
// Sorting bands by COUNT would destroy the band-plan reading; the order is the
|
||||||
|
// information.
|
||||||
|
|
||||||
|
function VBars({ data, empty, height = 150, showValues, colorful }: { data: Bucket[]; empty: string; height?: number; showValues?: boolean; colorful?: boolean }) {
|
||||||
|
const peak = Math.max(1, ...data.map((d) => d.count));
|
||||||
|
if (data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
// Thin the x labels once the bars get narrow. A label under EVERY one of 48
|
||||||
|
// hourly bars is unreadable mush — the axis exists to orient, not to enumerate
|
||||||
|
// (the value is in the tooltip, and every number is in the table view).
|
||||||
|
const every = data.length <= 16 ? 1 : Math.ceil(data.length / 12);
|
||||||
|
return (
|
||||||
|
// The container includes the x-axis band — a fixed height that excludes it is
|
||||||
|
// how cards end up with a tiny nested scrollbar.
|
||||||
|
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||||||
|
title={`${d.key} — ${nf(d.count)}`}>
|
||||||
|
<span className={cn('text-[9px] font-semibold mb-0.5 tabular-nums whitespace-nowrap',
|
||||||
|
showValues ? 'text-foreground' : 'text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity')}>
|
||||||
|
{nf(d.count)}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="w-full rounded-t-[4px] transition-[height] duration-300"
|
||||||
|
style={{ height: `${Math.max(2, (d.count / peak) * 100)}%`, background: colorful ? `var(--chart-${(i % 8) + 1})` : 'var(--chart-1)' }}
|
||||||
|
/>
|
||||||
|
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap overflow-visible">
|
||||||
|
{i % every === 0 ? d.key : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 ────────────────────────────────────────
|
||||||
|
// The categories (the operators) ARE the subject, so this is categorical colour,
|
||||||
|
// in the FIXED order the backend sends (busiest first). An operator therefore
|
||||||
|
// keeps the same hue everywhere on the page — a chart that repaints its series
|
||||||
|
// when the filter changes is a chart nobody can trust.
|
||||||
|
|
||||||
|
function RateStack({ rate, ops, byOp, empty, height = 130 }: {
|
||||||
|
rate: Bucket[]; ops: string[]; byOp: number[][]; empty: string; height?: number;
|
||||||
|
}) {
|
||||||
|
const [hov, setHov] = useState<number | null>(null);
|
||||||
|
if (rate.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
const peak = Math.max(1, ...rate.map((d) => d.count));
|
||||||
|
const every = rate.length <= 16 ? 1 : Math.ceil(rate.length / 12);
|
||||||
|
const single = ops.length <= 1; // one operator → one hue; a legend would be noise
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
|
||||||
|
{rate.map((d, i) => (
|
||||||
|
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full"
|
||||||
|
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}>
|
||||||
|
{/* The column is the hour's total; the segments are who made it. */}
|
||||||
|
<div className="w-full flex flex-col-reverse justify-start rounded-t-[4px] overflow-hidden"
|
||||||
|
style={{ height: `${Math.max(1, (d.count / peak) * 100)}%` }}>
|
||||||
|
{(byOp[i] ?? []).map((n, o) => n > 0 && (
|
||||||
|
<div key={o} style={{
|
||||||
|
height: `${(n / Math.max(1, d.count)) * 100}%`,
|
||||||
|
background: single ? 'var(--chart-1)' : `var(--chart-${(o % 8) + 1})`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">
|
||||||
|
{i % every === 0 ? d.key : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover read-out: the hour, its total, and the split. Values are also in the
|
||||||
|
rate sheet below, so nothing is reachable by hover alone. */}
|
||||||
|
<p className="mt-1 h-4 text-[11px] text-muted-foreground tabular-nums truncate">
|
||||||
|
{hov !== null && (
|
||||||
|
<>
|
||||||
|
<span className="text-foreground font-medium">{rate[hov].key}</span>
|
||||||
|
{' · '}{nf(rate[hov].count)} QSO
|
||||||
|
{!single && (byOp[hov] ?? []).map((n, o) => n > 0 && (
|
||||||
|
<span key={o}> · <span style={{ color: `var(--chart-${(o % 8) + 1})` }}>■</span> {ops[o]} {n}</span>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!single && (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1">
|
||||||
|
{ops.map((op, o) => (
|
||||||
|
<span key={op} className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||||
|
<span className="size-2.5 rounded-[3px]" style={{ background: `var(--chart-${(o % 8) + 1})` }} />
|
||||||
|
{op}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The rate sheet: hour × operator, with the hour total ─────────────────────
|
||||||
|
// Exact numbers, many of them — that is a table's job, not a chart's. Contesters
|
||||||
|
// read rate sheets as tables, and every value here is also the one the chart draws.
|
||||||
|
|
||||||
|
function RateSheet({ rate, ops, byOp }: { rate: Bucket[]; ops: string[]; byOp: number[][] }) {
|
||||||
|
if (rate.length === 0) return null;
|
||||||
|
const shown = rate.map((d, i) => ({ d, i })).filter(({ d }) => d.count > 0); // silent hours add nothing here
|
||||||
|
const totals = ops.map((_, o) => rate.reduce((s, _d, i) => s + (byOp[i]?.[o] ?? 0), 0));
|
||||||
|
const grand = rate.reduce((s, d) => s + d.count, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto max-h-[260px] rounded-md border border-border">
|
||||||
|
<table className="w-full text-[11px] tabular-nums">
|
||||||
|
<thead className="sticky top-0 bg-muted/60 backdrop-blur">
|
||||||
|
<tr className="text-muted-foreground">
|
||||||
|
<th className="text-left font-medium px-2 py-1">UTC</th>
|
||||||
|
{ops.map((op, o) => (
|
||||||
|
<th key={op} className="text-right font-medium px-2 py-1 whitespace-nowrap">
|
||||||
|
<span className="inline-block size-2 rounded-[2px] mr-1 align-middle"
|
||||||
|
style={{ background: `var(--chart-${(o % 8) + 1})` }} />
|
||||||
|
{op}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="text-right font-semibold px-2 py-1 text-foreground">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{shown.map(({ d, i }) => (
|
||||||
|
<tr key={d.key} className="border-t border-border/50">
|
||||||
|
<td className="px-2 py-0.5 text-muted-foreground whitespace-nowrap">{d.key}</td>
|
||||||
|
{ops.map((_, o) => (
|
||||||
|
<td key={o} className="px-2 py-0.5 text-right">
|
||||||
|
{byOp[i]?.[o] ? nf(byOp[i][o]) : <span className="text-muted-foreground/40">·</span>}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-2 py-0.5 text-right font-semibold">{nf(d.count)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="sticky bottom-0 bg-muted/60 backdrop-blur">
|
||||||
|
<tr className="border-t border-border font-semibold">
|
||||||
|
<td className="px-2 py-1">Total</td>
|
||||||
|
{totals.map((n, o) => <td key={o} className="px-2 py-1 text-right">{nf(n)}</td>)}
|
||||||
|
<td className="px-2 py-1 text-right">{nf(grand)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Activity over time (single series → area, one hue) ────────────────────────
|
||||||
|
// Only the PEAK and the LAST point are direct-labelled. A number on every point
|
||||||
|
// is chaos and goes unread.
|
||||||
|
|
||||||
|
function AreaTrend({ data, height = 160, empty }: { data: Bucket[]; height?: number; empty: string }) {
|
||||||
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
|
if (data.length < 2) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
|
||||||
|
const W = 1000, H = 100, peak = Math.max(1, ...data.map((d) => d.count));
|
||||||
|
const x = (i: number) => (i / (data.length - 1)) * W;
|
||||||
|
const y = (v: number) => H - (v / peak) * H;
|
||||||
|
const line = data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(d.count).toFixed(1)}`).join(' ');
|
||||||
|
const area = `${line} L${W},${H} L0,${H} Z`;
|
||||||
|
const peakIdx = data.reduce((b, d, i) => (d.count > data[b].count ? i : b), 0);
|
||||||
|
const h = hover ?? -1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="relative" style={{ height }}>
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-full overflow-visible"
|
||||||
|
onMouseLeave={() => setHover(null)}>
|
||||||
|
{/* Recessive hairline grid — solid, never dashed. */}
|
||||||
|
{[0, 0.5, 1].map((f) => (
|
||||||
|
<line key={f} x1={0} x2={W} y1={H * f} y2={H * f} stroke="var(--border)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||||
|
))}
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="statsArea" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="var(--chart-1)" stopOpacity="0.28" />
|
||||||
|
<stop offset="100%" stopColor="var(--chart-1)" stopOpacity="0.02" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={area} fill="url(#statsArea)" />
|
||||||
|
<path d={line} fill="none" stroke="var(--chart-1)" strokeWidth={2} vectorEffect="non-scaling-stroke"
|
||||||
|
strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
{h >= 0 && (
|
||||||
|
<>
|
||||||
|
<line x1={x(h)} x2={x(h)} y1={0} y2={H} stroke="var(--muted-foreground)" strokeWidth={1} vectorEffect="non-scaling-stroke" opacity={0.5} />
|
||||||
|
{/* 2px surface ring so the marker reads on top of the area. */}
|
||||||
|
<circle cx={x(h)} cy={y(data[h].count)} r={4} fill="var(--chart-1)" stroke="var(--card)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Hit areas are far wider than the marks — pinpoint targets are unusable. */}
|
||||||
|
{data.map((_, i) => (
|
||||||
|
<rect key={i} x={x(i) - W / data.length / 2} y={0} width={W / data.length} height={H}
|
||||||
|
fill="transparent" onMouseEnter={() => setHover(i)} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{h >= 0 && (
|
||||||
|
<div className="pointer-events-none absolute -top-1 z-10 rounded-md border border-border bg-popover px-2 py-1 text-[11px] shadow-lg whitespace-nowrap"
|
||||||
|
style={{ left: `${(h / (data.length - 1)) * 100}%`, transform: 'translateX(-50%)' }}>
|
||||||
|
<span className="text-muted-foreground">{data[h].key}</span>{' '}
|
||||||
|
<span className="font-semibold tabular-nums">{nf(data[h].count)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-1.5 text-[10px] text-muted-foreground tabular-nums">
|
||||||
|
<span>{data[0].key}</span>
|
||||||
|
<span className="text-foreground font-medium">↑ {data[peakIdx].key} · {nf(data[peakIdx].count)}</span>
|
||||||
|
<span>{data[data.length - 1].key}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Part-to-whole (continents) → the donut ───────────────────────────────────
|
||||||
|
// A pie/donut is legitimate for exactly this: part-to-whole, read at a glance,
|
||||||
|
// few segments, one share obviously dominant. It is the WRONG form for comparing
|
||||||
|
// close values (which is why modes stay bars — CW and SSB are nearly tied, and a
|
||||||
|
// donut would hide precisely the fact that makes them interesting).
|
||||||
|
//
|
||||||
|
// The categories ARE the subject here, so this is the one chart using the
|
||||||
|
// categorical slots — in FIXED order, with a legend and printed values, because
|
||||||
|
// the dark palette sits in the CVD floor band where labels are mandatory.
|
||||||
|
|
||||||
|
function Donut({ data, empty }: { data: Bucket[]; empty: string }) {
|
||||||
|
const [hov, setHov] = useState<number | null>(null);
|
||||||
|
const total = data.reduce((s, d) => s + d.count, 0);
|
||||||
|
if (total === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
|
||||||
|
const slots = data.slice(0, 8); // never generate a 9th hue
|
||||||
|
const R = 58, SW = 22, C = 2 * Math.PI * R;
|
||||||
|
let acc = 0;
|
||||||
|
const arcs = slots.map((d, i) => {
|
||||||
|
const frac = d.count / total;
|
||||||
|
// A 2px surface gap between segments — the correct separator, not a border.
|
||||||
|
const len = Math.max(0, frac * C - 2);
|
||||||
|
const a = { key: d.key, i, len, off: -acc * C, frac };
|
||||||
|
acc += frac;
|
||||||
|
return a;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 min-w-0">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<svg viewBox="0 0 160 160" className="size-[140px] -rotate-90">
|
||||||
|
{arcs.map((a) => (
|
||||||
|
<circle key={a.key} cx={80} cy={80} r={R} fill="none"
|
||||||
|
stroke={`var(--chart-${a.i + 1})`} strokeWidth={hov === a.i ? SW + 4 : SW}
|
||||||
|
strokeDasharray={`${a.len} ${C - a.len}`} strokeDashoffset={a.off}
|
||||||
|
className="transition-[stroke-width] duration-150 cursor-default"
|
||||||
|
onMouseEnter={() => setHov(a.i)} onMouseLeave={() => setHov(null)} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{/* The hole is not decoration — it carries the total the slices add up to. */}
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
{hov === null ? (
|
||||||
|
<>
|
||||||
|
<span className="text-[17px] font-semibold leading-none">{nf(total)}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground mt-0.5">QSO</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-[15px] font-semibold leading-none">{(arcs[hov].frac * 100).toFixed(1)}%</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground mt-0.5 truncate max-w-[92px] text-center">
|
||||||
|
{CONTINENT_NAME[slots[hov].key] ?? slots[hov].key}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="flex-1 min-w-0 flex flex-col gap-1">
|
||||||
|
{slots.map((d, i) => (
|
||||||
|
<li key={d.key}
|
||||||
|
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}
|
||||||
|
className={cn('flex items-center gap-1.5 min-w-0 text-[11px] rounded px-1 -mx-1',
|
||||||
|
hov === i && 'bg-muted')}>
|
||||||
|
{/* The swatch carries identity; the text stays in ink tokens. */}
|
||||||
|
<span className="size-2.5 rounded-[3px] shrink-0" style={{ background: `var(--chart-${i + 1})` }} />
|
||||||
|
<span className="truncate text-muted-foreground">{CONTINENT_NAME[d.key] ?? d.key}</span>
|
||||||
|
<span className="ml-auto shrink-0 tabular-nums text-foreground">{nf(d.count)}</span>
|
||||||
|
<span className="w-9 shrink-0 text-right tabular-nums text-muted-foreground">
|
||||||
|
{((d.count / total) * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── A single ratio against a limit → a meter, not a 2-slice pie ───────────────
|
||||||
|
|
||||||
|
function Meter({ label, value, total }: { label: string; value: number; total: number }) {
|
||||||
|
const pct = total > 0 ? (value / total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||||||
|
<span className="text-[11px] text-muted-foreground truncate">{label}</span>
|
||||||
|
<span className="text-[11px] tabular-nums">
|
||||||
|
<span className="font-semibold text-foreground">{pct.toFixed(1)}%</span>
|
||||||
|
<span className="text-muted-foreground"> · {nf(value)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Same-ramp track: the meter and its track are one hue, not two. */}
|
||||||
|
<div className="h-2 w-full rounded-full" style={{ background: 'var(--chart-seq-1)' }}>
|
||||||
|
<div className="h-full rounded-full transition-[width] duration-500"
|
||||||
|
style={{ width: `${Math.max(1, pct)}%`, background: 'var(--chart-1)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Period ───────────────────────────────────────────────────────────────────
|
||||||
|
// ONE filter row above everything it scopes — never a control inside a card, or
|
||||||
|
// the charts would each show a different slice of time.
|
||||||
|
|
||||||
|
type Period = 'all' | 'ytd' | 'y12' | 'd30' | 'custom';
|
||||||
|
|
||||||
|
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Resolve a preset to the [from, to] the backend expects. Empty = no bound.
|
||||||
|
function periodRange(p: Period, from: string, to: string): [string, string] {
|
||||||
|
const now = new Date();
|
||||||
|
switch (p) {
|
||||||
|
case 'ytd': return [`${now.getUTCFullYear()}-01-01`, ''];
|
||||||
|
case 'y12': {
|
||||||
|
const d = new Date(now); d.setUTCMonth(d.getUTCMonth() - 12);
|
||||||
|
return [iso(d), ''];
|
||||||
|
}
|
||||||
|
case 'd30': {
|
||||||
|
const d = new Date(now); d.setUTCDate(d.getUTCDate() - 30);
|
||||||
|
return [iso(d), ''];
|
||||||
|
}
|
||||||
|
case 'custom': return [from, to];
|
||||||
|
default: return ['', ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
||||||
|
const total = data.reduce((s, d) => s + d.count, 0);
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
<p className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground border-b border-border bg-muted/30">{title}</p>
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<tbody>
|
||||||
|
{data.map((d) => (
|
||||||
|
<tr key={d.key} className="border-b border-border/50 last:border-0">
|
||||||
|
<td className="px-3 py-1 truncate">{d.key}</td>
|
||||||
|
<td className="px-3 py-1 text-right tabular-nums font-medium">{nf(d.count)}</td>
|
||||||
|
<td className="px-3 py-1 text-right tabular-nums text-muted-foreground w-14">
|
||||||
|
{total ? ((d.count / total) * 100).toFixed(1) + '%' : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{data.length === 0 && <tr><td className="px-3 py-2 text-muted-foreground italic">—</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Panel ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function StatsPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [view, setView] = useState<'charts' | 'table'>('charts');
|
||||||
|
const [period, setPeriod] = useState<Period>('all');
|
||||||
|
const [from, setFrom] = useState('');
|
||||||
|
const [to, setTo] = useState('');
|
||||||
|
// Contest picker: "ID|YEAR", or '' for none. Selecting one narrows the log to
|
||||||
|
// that contest AND lets the window derive from its own span — no date typing.
|
||||||
|
const [runs, setRuns] = useState<ContestRun[]>([]);
|
||||||
|
const [contest, setContest] = useState('');
|
||||||
|
// Operator picker: '' = all operators, "—" = station owner (empty OPERATOR).
|
||||||
|
const [operators, setOperators] = useState<string[]>([]);
|
||||||
|
const [operator, setOperator] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => { GetContestRuns().then((r: any) => setRuns((r ?? []) as ContestRun[])).catch(() => {}); }, []);
|
||||||
|
useEffect(() => { GetOperators().then((o: any) => setOperators((o ?? []) as string[])).catch(() => {}); }, []);
|
||||||
|
|
||||||
|
const load = async (p: Period = period, f = from, t2 = to, c = contest, op = operator) => {
|
||||||
|
// A contest defines its own window (its first→last QSO), so we send no dates
|
||||||
|
// with it — the backend derives them. Sending a period as well would be two
|
||||||
|
// filters fighting over the same axis.
|
||||||
|
const [cid, cyr] = c ? c.split('|') : ['', '0'];
|
||||||
|
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
|
||||||
|
setBusy(true); setErr('');
|
||||||
|
try {
|
||||||
|
const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0, op)) as any;
|
||||||
|
// Harden the boundary: a Go nil slice arrives as JSON null, and a single
|
||||||
|
// .length on null unmounts the whole React tree — a white screen. Normalise
|
||||||
|
// once, here, rather than guarding at every use site and missing one.
|
||||||
|
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
||||||
|
setStats({
|
||||||
|
...raw,
|
||||||
|
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),
|
||||||
|
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_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
||||||
|
} as Stats);
|
||||||
|
}
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
// Re-run whenever the filter changes. A custom range only fires once BOTH ends
|
||||||
|
// are set — otherwise every keystroke in the date box would re-scan the log.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contest && period === 'custom' && !(from && to)) return;
|
||||||
|
load(period, from, to, contest, operator);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [period, from, to, contest, operator]);
|
||||||
|
|
||||||
|
// The rate / off-air block belongs to a CONTEST-shaped effort, and nowhere else.
|
||||||
|
// Over a year it degenerates into nonsense — "4 156 h off air", a 94-hour "break"
|
||||||
|
// between two ordinary evenings — numbers that are true and completely useless.
|
||||||
|
// So: show it for a picked contest, or for a hand-set range short enough to be a
|
||||||
|
// real operating session (which is exactly when an hourly rate chart exists).
|
||||||
|
const windowed = contest !== '' || (period === 'custom' && (stats?.rate?.length ?? 0) > 1);
|
||||||
|
|
||||||
|
const span = useMemo(() => {
|
||||||
|
if (!stats?.first_qso) return '';
|
||||||
|
const f = new Date(stats.first_qso), l = new Date(stats.last_qso);
|
||||||
|
return `${f.toISOString().slice(0, 10)} → ${l.toISOString().slice(0, 10)}`;
|
||||||
|
}, [stats]);
|
||||||
|
|
||||||
|
if (busy && !stats) {
|
||||||
|
return <div className="flex-1 flex items-center justify-center text-muted-foreground gap-2">
|
||||||
|
<Loader2 className="size-4 animate-spin" /> <span className="text-xs">{t('stats.loading')}</span>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
if (err) return <div className="p-4 text-xs text-destructive">{err}</div>;
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
const empty = t('stats.noData');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto p-3">
|
||||||
|
{/* ONE filter/action row above everything it scopes — never per-card controls. */}
|
||||||
|
<div className="flex items-center flex-wrap gap-2 mb-3">
|
||||||
|
<h2 className="text-sm font-semibold">{t('stats.title')}</h2>
|
||||||
|
{span && <span className="text-[11px] text-muted-foreground tabular-nums">{span}</span>}
|
||||||
|
|
||||||
|
{/* A contest is picked from what's actually IN the log (CONTEST_ID + year),
|
||||||
|
never from a static list — so it can't offer a contest you never entered.
|
||||||
|
Choosing one supersedes the period: it brings its own window. */}
|
||||||
|
<select value={contest} onChange={(e) => setContest(e.target.value)}
|
||||||
|
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[240px]">
|
||||||
|
<option value="">{t('stats.noContest')}</option>
|
||||||
|
{runs.map((r) => (
|
||||||
|
<option key={`${r.id}|${r.year}`} value={`${r.id}|${r.year}`}>
|
||||||
|
{r.id} {r.year} — {nf(r.count)} QSO
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Operator picker — narrows every stat (totals, DXCC, top countries,
|
||||||
|
continent split, rate) to one operator from the log. "—" = the
|
||||||
|
station owner (QSOs logged with no OPERATOR set). */}
|
||||||
|
{operators.length > 1 && (
|
||||||
|
<select value={operator} onChange={(e) => setOperator(e.target.value)}
|
||||||
|
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[180px]"
|
||||||
|
title={t('stats.operatorTip')}>
|
||||||
|
<option value="">{t('stats.allOperators')}</option>
|
||||||
|
{operators.map((op) => (
|
||||||
|
<option key={op} value={op}>{op === '—' ? t('stats.stationOwner') : op}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cn('inline-flex rounded-md border border-border overflow-hidden', contest && 'opacity-40 pointer-events-none')}>
|
||||||
|
{([
|
||||||
|
['all', t('stats.pAll')], ['ytd', t('stats.pYTD')],
|
||||||
|
['y12', t('stats.p12m')], ['d30', t('stats.p30d')], ['custom', t('stats.pCustom')],
|
||||||
|
] as [Period, string][]).map(([p, label], i) => (
|
||||||
|
<button key={p} onClick={() => setPeriod(p)}
|
||||||
|
className={cn('px-2 h-7 text-xs whitespace-nowrap', i > 0 && 'border-l border-border',
|
||||||
|
period === p ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!contest && period === 'custom' && (
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)}
|
||||||
|
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
|
||||||
|
<span className="text-xs text-muted-foreground">→</span>
|
||||||
|
<input type="date" value={to} onChange={(e) => setTo(e.target.value)}
|
||||||
|
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
<button onClick={() => setView('charts')}
|
||||||
|
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs', view === 'charts' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||||
|
<BarChart3 className="size-3.5" />{t('stats.charts')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setView('table')}
|
||||||
|
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs border-l border-border', view === 'table' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||||
|
<Table2 className="size-3.5" />{t('stats.table')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => load()} disabled={busy} title={t('stats.refresh')}
|
||||||
|
className="inline-flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-50">
|
||||||
|
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Headline figures: stat tiles, not a grouped bar chart. */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
||||||
|
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} accent="var(--chart-1)" />
|
||||||
|
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} accent="var(--chart-2)" />
|
||||||
|
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" accent="var(--chart-5)" />
|
||||||
|
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" accent="var(--chart-8)" />
|
||||||
|
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
||||||
|
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} accent="var(--success)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
|
||||||
|
seventeen years is noise; across a contest weekend it IS the result. */}
|
||||||
|
{windowed && stats.total > 0 && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5 mb-3">
|
||||||
|
<Card title={t('stats.rate')} sub={t('stats.rateSub')} className="lg:col-span-2">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
|
||||||
|
{/* Two rates on purpose: one honest, one flattering — see the tooltip. */}
|
||||||
|
<div title={t('stats.avgWindowTip')}>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgWindow')}</p>
|
||||||
|
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_hour.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
|
||||||
|
</div>
|
||||||
|
<div title={t('stats.avgActiveTip')}>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgActive')}</p>
|
||||||
|
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_active.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
|
||||||
|
</div>
|
||||||
|
<div title={t('stats.best60Tip')}>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.best60')}</p>
|
||||||
|
<p className="text-[22px] leading-none font-semibold mt-1">{nf(stats.best_60)}</p>
|
||||||
|
</div>
|
||||||
|
{/* On-air + off-air = the window, by construction. The first version
|
||||||
|
counted "clock hours containing a QSO", which gave 39 h on air AND
|
||||||
|
16 h off air inside a 45 h contest — and rightly wasn't believed. */}
|
||||||
|
<div title={t('stats.onAirTip')}>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.activeHours')}</p>
|
||||||
|
<p className="text-[22px] leading-none font-semibold mt-1">
|
||||||
|
{dur(stats.on_air_minutes)}
|
||||||
|
<span className="text-[11px] text-muted-foreground font-normal"> / {Math.round(stats.window_hours)} h</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{stats.rate.length > 1
|
||||||
|
? <RateStack rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} empty={empty} />
|
||||||
|
: <p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.rateTooLong')}</p>}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('stats.offAir')} sub={t('stats.offAirSub', { d: dur(stats.off_air_minutes) })}>
|
||||||
|
{stats.gaps.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.noGaps')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1 min-w-0">
|
||||||
|
{stats.gaps.map((g, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-2 text-[11px] min-w-0">
|
||||||
|
<span className="tabular-nums text-muted-foreground truncate">
|
||||||
|
{g.start.slice(5, 16).replace('T', ' ')} → {g.end.slice(11, 16)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 h-[6px] rounded-full min-w-0"
|
||||||
|
style={{
|
||||||
|
background: 'var(--chart-seq-1)',
|
||||||
|
}}>
|
||||||
|
<span className="block h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (g.minutes / Math.max(1, stats.gaps[0].minutes)) * 100)}%`,
|
||||||
|
background: 'var(--chart-1)',
|
||||||
|
}} />
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 tabular-nums font-medium">{dur(g.minutes)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* The rate sheet: exact numbers, many of them, hour by hour and operator
|
||||||
|
by operator. That is a table's job, not a chart's — and it's how
|
||||||
|
contesters actually read a run. Every value here is also what the
|
||||||
|
stacked chart above draws, so the two can never disagree. */}
|
||||||
|
{stats.rate.length > 1 && (
|
||||||
|
<Card title={t('stats.rateSheet')} sub={t('stats.rateSheetSub')} className="lg:col-span-3">
|
||||||
|
<RateSheet rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'table' ? (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5">
|
||||||
|
<BucketTable title={t('stats.byBand')} data={stats.by_band} />
|
||||||
|
<BucketTable title={t('stats.byMode')} data={stats.by_mode} />
|
||||||
|
<BucketTable title={t('stats.byOperator')} data={stats.by_operator} />
|
||||||
|
<BucketTable title={t('stats.byContinent')} data={stats.by_continent} />
|
||||||
|
<BucketTable title={t('stats.topEntities')} data={stats.top_entities} />
|
||||||
|
<BucketTable title={t('stats.byYear')} data={stats.by_year} />
|
||||||
|
<BucketTable title={t('stats.byStation')} data={stats.by_station} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||||
|
<Card title={t('stats.bandSplit')} sub={t('stats.bandSplitSub')} accent="var(--chart-3)">
|
||||||
|
<StackedBandBars data={stats.by_band_category} empty={empty} />
|
||||||
|
</Card>
|
||||||
|
<Card title={t('stats.byMode')} accent="var(--chart-2)">
|
||||||
|
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ActivityCard stats={stats} t={t} empty={empty} />
|
||||||
|
|
||||||
|
{/* 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
|
||||||
|
instead of truncating. */}
|
||||||
|
<Card title={t('stats.byOperator')} accent="var(--chart-5)">
|
||||||
|
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
||||||
|
<HBars data={stats.by_operator} empty={empty} share color="var(--chart-5)" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')} accent="var(--chart-6)">
|
||||||
|
<Donut data={stats.by_continent} empty={empty} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('stats.topEntities')} accent="var(--chart-4)">
|
||||||
|
<HBars data={stats.top_entities} max={12} empty={empty} labelWidth="w-40" color="var(--chart-4)" />
|
||||||
|
</Card>
|
||||||
|
<div className="flex flex-col gap-2.5 min-w-0">
|
||||||
|
<Card title={t('stats.confirmations')} className="flex-1" accent="var(--success)">
|
||||||
|
<div className="flex flex-col gap-3 justify-center flex-1">
|
||||||
|
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
|
||||||
|
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
|
||||||
|
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card title={t('stats.byStation')} accent="var(--chart-8)">
|
||||||
|
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
|
||||||
|
<HBars data={stats.by_station} empty={empty} share color="var(--chart-8)" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -321,7 +321,6 @@ function EditDialog({
|
|||||||
<Label>{t('udpp.name')}</Label>
|
<Label>{t('udpp.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder={draft.direction === 'inbound' ? t('udpp.namePhInbound') : t('udpp.namePhOutbound')}
|
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -64,7 +64,17 @@ export function Combobox({
|
|||||||
// Focus selects the text so a keystroke replaces it — but does NOT
|
// Focus selects the text so a keystroke replaces it — but does NOT
|
||||||
// open the list (so tabbing in doesn't pop the dropdown).
|
// open the list (so tabbing in doesn't pop the dropdown).
|
||||||
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
|
||||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (commitOnType) onChange(e.target.value); }}
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setQuery(v);
|
||||||
|
setOpen(true);
|
||||||
|
// Commit-on-type pushes the value live to the parent (so a CW macro sent
|
||||||
|
// without leaving the field uses what was just typed). With free text that's
|
||||||
|
// any input; a restricted field (allowFreeText=false) commits ONLY a value
|
||||||
|
// that's actually in the list, so a half-typed or invalid report never
|
||||||
|
// becomes the committed value — blur then reverts the leftover text.
|
||||||
|
if (commitOnType && (allowFreeText || options.some((o) => o.toLowerCase() === v.trim().toLowerCase()))) onChange(v);
|
||||||
|
}}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
|
||||||
|
|||||||
@@ -6,11 +6,32 @@
|
|||||||
// back to the DB copy and re-seed the cache.
|
// back to the DB copy and re-seed the cache.
|
||||||
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// The DB copy is ALREADY per-profile (the backend prefixes every ui.* key with
|
||||||
|
// the active profile id). The localStorage cache, however, is one namespace for
|
||||||
|
// the whole WebView, so without scoping it too a profile switch would keep
|
||||||
|
// serving the previous profile's cached layout and the correct per-profile DB
|
||||||
|
// value would never win. lsScope makes the cache per-profile as well; it's set
|
||||||
|
// once the active profile is known and updated on every profile switch.
|
||||||
|
let lsScope = '';
|
||||||
|
|
||||||
|
// setGridPrefsProfile scopes the localStorage cache to a profile. Call it before
|
||||||
|
// the grids read their state (at startup) and again whenever the active profile
|
||||||
|
// changes so each profile keeps its own column layout / widths.
|
||||||
|
export function setGridPrefsProfile(id: number | string | null | undefined): void {
|
||||||
|
lsScope = id == null || id === '' ? '' : `p${id}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
|
||||||
|
// GetUIPref/SetUIPref is left untouched — the backend already scopes it.
|
||||||
|
function lsKey(key: string): string {
|
||||||
|
return lsScope + key;
|
||||||
|
}
|
||||||
|
|
||||||
// loadLocal reads the cached column state synchronously (used in onGridReady
|
// loadLocal reads the cached column state synchronously (used in onGridReady
|
||||||
// to apply instantly, before the async DB round-trip).
|
// to apply instantly, before the async DB round-trip).
|
||||||
export function loadLocal(key: string): any[] | null {
|
export function loadLocal(key: string): any[] | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(key);
|
const raw = localStorage.getItem(lsKey(key));
|
||||||
const v = raw ? JSON.parse(raw) : null;
|
const v = raw ? JSON.parse(raw) : null;
|
||||||
return Array.isArray(v) ? v : null;
|
return Array.isArray(v) ? v : null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -29,15 +50,16 @@ export async function loadRemote(key: string): Promise<any[] | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveState write-throughs to both the cache and the DB (fire-and-forget).
|
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only
|
||||||
|
// the cache key is profile-scoped; the DB key is scoped by the backend.
|
||||||
export function saveState(key: string, state: any[]) {
|
export function saveState(key: string, state: any[]) {
|
||||||
const json = JSON.stringify(state);
|
const json = JSON.stringify(state);
|
||||||
try { localStorage.setItem(key, json); } catch { /* quota / private mode */ }
|
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
|
||||||
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedLocal writes a value into the cache without touching the DB (used after
|
// seedLocal writes a value into the cache without touching the DB (used after
|
||||||
// hydrating the cache from the DB on a fresh machine).
|
// hydrating the cache from the DB on a fresh machine).
|
||||||
export function seedLocal(key: string, state: any[]) {
|
export function seedLocal(key: string, state: any[]) {
|
||||||
try { localStorage.setItem(key, JSON.stringify(state)); } catch { /* ignore */ }
|
try { localStorage.setItem(lsKey(key), JSON.stringify(state)); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|||||||
+274
-50
File diff suppressed because one or more lines are too long
@@ -14,3 +14,25 @@ export function sMeterRST(s: number, overDb: number, mode?: string): string {
|
|||||||
if (overDb > 0) return `59+${Math.ceil(overDb / 5) * 5}`;
|
if (overDb > 0) return `59+${Math.ceil(overDb / 5) * 5}`;
|
||||||
return `5${strength}`;
|
return `5${strength}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RST dropdown lists, shared by the entry form and the QSO editor so both offer
|
||||||
|
// the same per-mode choices (Settings → Modes → RST report lists).
|
||||||
|
export type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
|
||||||
|
|
||||||
|
// rstCategory maps an ADIF mode to its RST family (phone / cw / digital).
|
||||||
|
export function rstCategory(mode: string): keyof RSTLists {
|
||||||
|
const m = (mode || '').toUpperCase();
|
||||||
|
const digital = ['FT8', 'FT4', 'JT65', 'JT9', 'JS8', 'Q65', 'MSK144', 'FST4', 'FST4W', 'MFSK', 'OLIVIA', 'JT4', 'WSPR'];
|
||||||
|
if (digital.includes(m)) return 'digital';
|
||||||
|
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
|
||||||
|
return 'phone';
|
||||||
|
}
|
||||||
|
|
||||||
|
// rstOptions returns the valid report choices for a mode from the user's
|
||||||
|
// editable lists, with a tiny fallback before they load.
|
||||||
|
export function rstOptions(mode: string, lists: RSTLists): string[] {
|
||||||
|
const cat = rstCategory(mode);
|
||||||
|
const l = lists[cat];
|
||||||
|
if (l && l.length) return l;
|
||||||
|
return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
|
||||||
import { writeUiPref } from './uiPref';
|
import { writeUiPref } from './uiPref';
|
||||||
|
import { GetUIPref } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
// Theme system. Each choice maps to a `data-theme` value on <html> that the
|
// Theme system. Each choice maps to a `data-theme` value on <html> that the
|
||||||
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
||||||
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
||||||
// travels with the data/ folder like the language).
|
// travels with the data/ folder like the language).
|
||||||
export type ThemeChoice = 'auto' | 'light-warm' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
|
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
|
||||||
|
|
||||||
// Selectable, concrete themes (excludes 'auto') in display order.
|
// Selectable, concrete themes (excludes 'auto') in display order.
|
||||||
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
||||||
'light-warm', 'dark-warm', 'dark-graphite', 'high-contrast',
|
'light-warm', 'light-cool', 'light-sage', 'dim-slate', 'dark-warm', 'dark-graphite', 'high-contrast',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const LS_KEY = 'opslog.theme';
|
export const LS_KEY = 'opslog.theme';
|
||||||
@@ -49,13 +50,44 @@ export function useTheme(): Ctx { return useContext(ThemeCtx); }
|
|||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [theme, setThemeState] = useState<ThemeChoice>(() => readStored());
|
const [theme, setThemeState] = useState<ThemeChoice>(() => readStored());
|
||||||
|
// Set once the operator changes the theme by hand, so the self-heal below
|
||||||
|
// never clobbers a fresh choice with a value it read a moment earlier.
|
||||||
|
const userPicked = useRef(false);
|
||||||
|
|
||||||
const setTheme = useCallback((t: ThemeChoice) => {
|
const setTheme = useCallback((t: ThemeChoice) => {
|
||||||
|
userPicked.current = true;
|
||||||
setThemeState(t);
|
setThemeState(t);
|
||||||
applyThemeToDom(t);
|
applyThemeToDom(t);
|
||||||
writeUiPref(LS_KEY, t);
|
writeUiPref(LS_KEY, t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Self-heal the persisted theme. The synchronous boot read (localStorage) can
|
||||||
|
// miss it when the WebView cleared its storage, OR when syncPortablePrefs ran
|
||||||
|
// while the backend was still starting (settings store not wired yet → GetUIPref
|
||||||
|
// returned "" with no error, so nothing was restored) — the "restart lands on
|
||||||
|
// the light theme sometimes" bug. Re-read the portable pref from the DB once the
|
||||||
|
// backend is up and apply it, retrying briefly to ride out a slow startup.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let tries = 0;
|
||||||
|
const load = () => {
|
||||||
|
tries += 1;
|
||||||
|
GetUIPref(LS_KEY).then((raw) => {
|
||||||
|
if (cancelled || userPicked.current) return;
|
||||||
|
const v = raw as ThemeChoice;
|
||||||
|
if (v && ALL.includes(v)) {
|
||||||
|
try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ }
|
||||||
|
applyThemeToDom(v); // idempotent — safe to call unconditionally
|
||||||
|
setThemeState(v);
|
||||||
|
return; // restored
|
||||||
|
}
|
||||||
|
if (tries < 8) window.setTimeout(load, 300); // empty (unset or backend not ready yet) → retry
|
||||||
|
}).catch(() => { if (!cancelled && tries < 8) window.setTimeout(load, 300); });
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// While in 'auto', re-resolve when the OS light/dark preference flips.
|
// While in 'auto', re-resolve when the OS light/dark preference flips.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (theme !== 'auto') return;
|
if (theme !== 'auto') return;
|
||||||
|
|||||||
@@ -19,13 +19,24 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.showRotor', // rotor compass shown next to the keyers
|
'opslog.showRotor', // rotor compass shown next to the keyers
|
||||||
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
||||||
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
||||||
|
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
|
||||||
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
|
'opslog.catModeBeforeFreq', // send CAT mode before frequency (older rigs)
|
||||||
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
||||||
'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.
|
||||||
|
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
||||||
|
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
|
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||||
|
'opslog.activeTab', // last selected tab
|
||||||
|
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||||
|
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||||
|
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||||
|
// through this global path would fight that per-profile scoping.
|
||||||
];
|
];
|
||||||
|
|
||||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||||
|
|||||||
@@ -92,6 +92,207 @@
|
|||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 1b: Cool light (slate/blue) — crisp daylight, neutral cool --- */
|
||||||
|
[data-theme="light-cool"] {
|
||||||
|
--background: #f4f6f8; /* cool blue-white */
|
||||||
|
--foreground: #0f172a; /* slate-900 */
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-foreground: #0f172a;
|
||||||
|
--popover: #ffffff;
|
||||||
|
--popover-foreground: #0f172a;
|
||||||
|
--primary: #2563eb; /* blue-600 */
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #e2e8f0; /* slate-200 */
|
||||||
|
--secondary-foreground: #0f172a;
|
||||||
|
--muted: #e6ebf1; /* toolbars / table headers */
|
||||||
|
--muted-foreground: #475569; /* slate-600 — readable on muted */
|
||||||
|
--accent: #dbeafe; /* blue-100 */
|
||||||
|
--accent-foreground: #1e3a8a; /* blue-900 */
|
||||||
|
--destructive: #dc2626;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--destructive-muted: #fef2f2;
|
||||||
|
--destructive-muted-foreground: #b91c1c;
|
||||||
|
--border: #d3dae2; /* cool slate border */
|
||||||
|
--input: #d3dae2;
|
||||||
|
--ring: #3b82f6; /* blue-500 focus ring */
|
||||||
|
|
||||||
|
--success: #16a34a;
|
||||||
|
--success-foreground: #ffffff;
|
||||||
|
--success-muted: #dcfce7;
|
||||||
|
--success-muted-foreground: #166534;
|
||||||
|
--success-border: #86efac;
|
||||||
|
|
||||||
|
--warning: #d97706;
|
||||||
|
--warning-foreground: #ffffff;
|
||||||
|
--warning-muted: #fef3c7;
|
||||||
|
--warning-muted-foreground: #92400e;
|
||||||
|
--warning-border: #fcd34d;
|
||||||
|
|
||||||
|
--caution: #ca8a04;
|
||||||
|
--caution-foreground: #1c1917;
|
||||||
|
--caution-muted: #fef9c3;
|
||||||
|
--caution-muted-foreground: #854d0e;
|
||||||
|
--caution-border: #fde047;
|
||||||
|
|
||||||
|
--danger: #e11d48;
|
||||||
|
--danger-foreground: #ffffff;
|
||||||
|
--danger-muted: #ffe4e6;
|
||||||
|
--danger-muted-foreground: #9f1239;
|
||||||
|
--danger-border: #fda4af;
|
||||||
|
|
||||||
|
--info: #0284c7;
|
||||||
|
--info-foreground: #ffffff;
|
||||||
|
--info-muted: #e0f2fe;
|
||||||
|
--info-muted-foreground: #075985;
|
||||||
|
--info-border: #7dd3fc;
|
||||||
|
|
||||||
|
/* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, cool neutral base. */
|
||||||
|
--mx-call-conf: #15803d;
|
||||||
|
--mx-call-work: #6ee7b7;
|
||||||
|
--mx-dx-conf: #3730a3;
|
||||||
|
--mx-dx-work: #a5b4fc;
|
||||||
|
--mx-none: #e2e8f0;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #b6c2d1;
|
||||||
|
--scrollbar-thumb-hover: #8fa1b5;
|
||||||
|
--card-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(15, 23, 42, 0.02);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 1c: Sage light (Nordic) — soft green-grey, low-fatigue ------- */
|
||||||
|
[data-theme="light-sage"] {
|
||||||
|
--background: #eef1ec; /* pale sage */
|
||||||
|
--foreground: #1a2420; /* deep pine-ink */
|
||||||
|
--card: #f8faf6; /* lifted off-white with a green cast */
|
||||||
|
--card-foreground: #1a2420;
|
||||||
|
--popover: #f8faf6;
|
||||||
|
--popover-foreground: #1a2420;
|
||||||
|
--primary: #2f855a; /* deep sage green */
|
||||||
|
--primary-foreground: #f0fdf4;
|
||||||
|
--secondary: #dde5db;
|
||||||
|
--secondary-foreground: #1a2420;
|
||||||
|
--muted: #dde5db; /* toolbars / table headers */
|
||||||
|
--muted-foreground: #445048; /* muted pine — readable on muted */
|
||||||
|
--accent: #d7e8d5; /* soft green */
|
||||||
|
--accent-foreground: #1e4634; /* forest-900 */
|
||||||
|
--destructive: #b91c1c;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--destructive-muted: #fef2f2;
|
||||||
|
--destructive-muted-foreground: #b91c1c;
|
||||||
|
--border: #cdd8ce; /* soft sage border */
|
||||||
|
--input: #cdd8ce;
|
||||||
|
--ring: #38a169; /* green focus ring */
|
||||||
|
|
||||||
|
--success: #16a34a;
|
||||||
|
--success-foreground: #ffffff;
|
||||||
|
--success-muted: #dcfce7;
|
||||||
|
--success-muted-foreground: #166534;
|
||||||
|
--success-border: #86efac;
|
||||||
|
|
||||||
|
--warning: #d97706;
|
||||||
|
--warning-foreground: #ffffff;
|
||||||
|
--warning-muted: #fef3c7;
|
||||||
|
--warning-muted-foreground: #92400e;
|
||||||
|
--warning-border: #fcd34d;
|
||||||
|
|
||||||
|
--caution: #ca8a04;
|
||||||
|
--caution-foreground: #1c1917;
|
||||||
|
--caution-muted: #fef9c3;
|
||||||
|
--caution-muted-foreground: #854d0e;
|
||||||
|
--caution-border: #fde047;
|
||||||
|
|
||||||
|
--danger: #e11d48;
|
||||||
|
--danger-foreground: #ffffff;
|
||||||
|
--danger-muted: #ffe4e6;
|
||||||
|
--danger-muted-foreground: #9f1239;
|
||||||
|
--danger-border: #fda4af;
|
||||||
|
|
||||||
|
--info: #0284c7;
|
||||||
|
--info-foreground: #ffffff;
|
||||||
|
--info-muted: #e0f2fe;
|
||||||
|
--info-muted-foreground: #075985;
|
||||||
|
--info-border: #7dd3fc;
|
||||||
|
|
||||||
|
/* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, sage neutral base. */
|
||||||
|
--mx-call-conf: #15803d;
|
||||||
|
--mx-call-work: #6ee7b7;
|
||||||
|
--mx-dx-conf: #3730a3;
|
||||||
|
--mx-dx-work: #a5b4fc;
|
||||||
|
--mx-none: #dde5db;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #b3c2b3;
|
||||||
|
--scrollbar-thumb-hover: #91a591;
|
||||||
|
--card-shadow: 0 1px 2px rgba(26, 36, 32, 0.06), 0 0 0 1px rgba(26, 36, 32, 0.02);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 1d: Dim slate — mid-tone twilight, between light and dark ---- */
|
||||||
|
[data-theme="dim-slate"] {
|
||||||
|
--background: #343b47; /* medium slate — light text, not deep dark */
|
||||||
|
--foreground: #eceef2;
|
||||||
|
--card: #3d4552; /* lifted panel */
|
||||||
|
--card-foreground: #eceef2;
|
||||||
|
--popover: #3d4552;
|
||||||
|
--popover-foreground: #eceef2;
|
||||||
|
--primary: #fb923c; /* orange-400 — brand warmth, pops on slate */
|
||||||
|
--primary-foreground: #241206;
|
||||||
|
--secondary: #454e5c;
|
||||||
|
--secondary-foreground: #eceef2;
|
||||||
|
--muted: #414a58; /* toolbars / table headers */
|
||||||
|
--muted-foreground: #b4bcc9; /* readable on muted */
|
||||||
|
--accent: #495468; /* hover / selection tint */
|
||||||
|
--accent-foreground: #dce3ef;
|
||||||
|
--destructive: #ef4444;
|
||||||
|
--destructive-foreground: #1a0808;
|
||||||
|
--destructive-muted: #3f1f1f;
|
||||||
|
--destructive-muted-foreground: #fca5a5;
|
||||||
|
--border: #4e5867;
|
||||||
|
--input: #4e5867;
|
||||||
|
--ring: #fdba74; /* orange-300 focus ring */
|
||||||
|
|
||||||
|
--success: #34d399;
|
||||||
|
--success-foreground: #06231a;
|
||||||
|
--success-muted: #1c3d34;
|
||||||
|
--success-muted-foreground: #6ee7b7;
|
||||||
|
--success-border: #2a5f4c;
|
||||||
|
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-foreground: #241a06;
|
||||||
|
--warning-muted: #41371a;
|
||||||
|
--warning-muted-foreground: #fcd34d;
|
||||||
|
--warning-border: #5e4d22;
|
||||||
|
|
||||||
|
--caution: #facc15;
|
||||||
|
--caution-foreground: #242006;
|
||||||
|
--caution-muted: #403b1a;
|
||||||
|
--caution-muted-foreground: #fde047;
|
||||||
|
--caution-border: #5b5222;
|
||||||
|
|
||||||
|
--danger: #fb7185;
|
||||||
|
--danger-foreground: #260a11;
|
||||||
|
--danger-muted: #421f28;
|
||||||
|
--danger-muted-foreground: #fda4af;
|
||||||
|
--danger-border: #63303c;
|
||||||
|
|
||||||
|
--info: #38bdf8;
|
||||||
|
--info-foreground: #061f2b;
|
||||||
|
--info-muted: #16384a;
|
||||||
|
--info-muted-foreground: #7dd3fc;
|
||||||
|
--info-border: #1d5670;
|
||||||
|
|
||||||
|
/* Matrix: bright confirmed, medium worked, slate not-worked (sits on dim). */
|
||||||
|
--mx-call-conf: #22c55e;
|
||||||
|
--mx-call-work: #2f7d55;
|
||||||
|
--mx-dx-conf: #6366f1;
|
||||||
|
--mx-dx-work: #3f3f8a;
|
||||||
|
--mx-none: #4a5262;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #565f70;
|
||||||
|
--scrollbar-thumb-hover: #6b7588;
|
||||||
|
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(236, 238, 242, 0.05);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Theme 2: Warm dark (espresso) — same soul, night mode ------------- */
|
/* ---- Theme 2: Warm dark (espresso) — same soul, night mode ------------- */
|
||||||
[data-theme="dark-warm"] {
|
[data-theme="dark-warm"] {
|
||||||
--background: #221d18;
|
--background: #221d18;
|
||||||
@@ -294,6 +495,63 @@
|
|||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Data-viz palette (Statistics dashboard) ────────────────────────────────
|
||||||
|
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
|
||||||
|
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
|
||||||
|
reorder, recycle, or generate a 9th hue — fold the tail into "Other" instead.
|
||||||
|
The dark column is the same eight hues re-stepped for a dark surface, NOT an
|
||||||
|
automatic inversion.
|
||||||
|
|
||||||
|
--chart-1 is the DEFAULT single-series hue: a chart with one series uses it for
|
||||||
|
every bar. Colour is for identity, never to re-encode a length the bar already
|
||||||
|
shows.
|
||||||
|
|
||||||
|
--chart-seq-* is a ONE-HUE ramp for magnitude (the hour×band heatmap). On light
|
||||||
|
themes it runs light→dark; on dark themes dark→light, so "near zero" always
|
||||||
|
recedes toward the surface instead of glowing. */
|
||||||
|
:root,
|
||||||
|
[data-theme="light-warm"],
|
||||||
|
[data-theme="light-cool"],
|
||||||
|
[data-theme="light-sage"] {
|
||||||
|
--chart-1: #2a78d6; /* blue — default single-series hue */
|
||||||
|
--chart-2: #1baf7a; /* aqua */
|
||||||
|
--chart-3: #eda100; /* yellow */
|
||||||
|
--chart-4: #008300; /* green */
|
||||||
|
--chart-5: #4a3aa7; /* violet */
|
||||||
|
--chart-6: #e34948; /* red */
|
||||||
|
--chart-7: #e87ba4; /* magenta */
|
||||||
|
--chart-8: #eb6834; /* orange */
|
||||||
|
|
||||||
|
--chart-seq-1: #cde2fb;
|
||||||
|
--chart-seq-2: #9ec5f4;
|
||||||
|
--chart-seq-3: #6da7ec;
|
||||||
|
--chart-seq-4: #3987e5;
|
||||||
|
--chart-seq-5: #256abf;
|
||||||
|
--chart-seq-6: #104281;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dim-slate"],
|
||||||
|
[data-theme="dark-warm"],
|
||||||
|
[data-theme="dark-graphite"],
|
||||||
|
[data-theme="high-contrast"] {
|
||||||
|
--chart-1: #3987e5;
|
||||||
|
--chart-2: #199e70;
|
||||||
|
--chart-3: #c98500;
|
||||||
|
--chart-4: #008300;
|
||||||
|
--chart-5: #9085e9;
|
||||||
|
--chart-6: #e66767;
|
||||||
|
--chart-7: #d55181;
|
||||||
|
--chart-8: #d95926;
|
||||||
|
|
||||||
|
/* Reversed: the lowest step must sink toward the dark surface. */
|
||||||
|
--chart-seq-1: #0d366b;
|
||||||
|
--chart-seq-2: #184f95;
|
||||||
|
--chart-seq-3: #256abf;
|
||||||
|
--chart-seq-4: #3987e5;
|
||||||
|
--chart-seq-5: #6da7ec;
|
||||||
|
--chart-seq-6: #9ec5f4;
|
||||||
|
}
|
||||||
|
|
||||||
/* Map Tailwind's color utilities onto the semantic vars above. `inline` makes
|
/* Map Tailwind's color utilities onto the semantic vars above. `inline` makes
|
||||||
the generated utilities reference var(--…) directly, so overriding a var in
|
the generated utilities reference var(--…) directly, so overriding a var in
|
||||||
a [data-theme] block re-skins every utility at runtime. */
|
a [data-theme] block re-skins every utility at runtime. */
|
||||||
|
|||||||
@@ -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.19.1';
|
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
+185
-11
@@ -5,21 +5,29 @@ 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 {winkeyer} from '../models';
|
import {winkeyer} from '../models';
|
||||||
import {alerts} from '../models';
|
import {alerts} from '../models';
|
||||||
import {audio} from '../models';
|
import {audio} from '../models';
|
||||||
import {contest} from '../models';
|
import {contest} from '../models';
|
||||||
import {operating} from '../models';
|
import {operating} from '../models';
|
||||||
import {udp} from '../models';
|
import {udp} from '../models';
|
||||||
|
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>;
|
||||||
@@ -28,14 +36,38 @@ 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>;
|
||||||
|
|
||||||
|
export function ApplyAwardImport(arg1:string,arg2:Record<string, string>):Promise<main.AwardImportResult>;
|
||||||
|
|
||||||
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
|
export function ApplyAwardUpdate(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||||
|
|
||||||
|
export function AudioMonitorActive():Promise<boolean>;
|
||||||
|
|
||||||
|
export function AudioStartMonitor():Promise<void>;
|
||||||
|
|
||||||
|
export function AudioStartTX():Promise<void>;
|
||||||
|
|
||||||
|
export function AudioStopMonitor():Promise<void>;
|
||||||
|
|
||||||
|
export function AudioStopTX():Promise<void>;
|
||||||
|
|
||||||
|
export function AudioTXActive():Promise<boolean>;
|
||||||
|
|
||||||
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function AwardFields():Promise<Array<string>>;
|
export function AwardFields():Promise<Array<string>>;
|
||||||
@@ -44,14 +76,16 @@ export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
|
|||||||
|
|
||||||
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
|
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
|
||||||
|
|
||||||
|
export function AwardsFolder():Promise<string>;
|
||||||
|
|
||||||
|
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
|
||||||
|
|
||||||
export function BrowseExecutable():Promise<string>;
|
export function BrowseExecutable():Promise<string>;
|
||||||
|
|
||||||
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
||||||
|
|
||||||
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>;
|
||||||
@@ -120,20 +154,34 @@ export function DisconnectClusterServer(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
|
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
|
||||||
|
|
||||||
|
export function DismissAwardUpdate(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DownloadAllReferenceLists():Promise<string>;
|
export function DownloadAllReferenceLists():Promise<string>;
|
||||||
|
|
||||||
|
export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||||
|
|
||||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
||||||
|
|
||||||
|
export function DownloadLoTWUsers():Promise<number>;
|
||||||
|
|
||||||
|
export function DownloadULSCounties():Promise<void>;
|
||||||
|
|
||||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||||
|
|
||||||
|
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
||||||
|
|
||||||
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
|
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
|
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
|
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>;
|
||||||
@@ -156,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>;
|
||||||
@@ -166,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>;
|
||||||
@@ -186,10 +240,24 @@ 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 FlexSetMon(arg1:boolean):Promise<void>;
|
export function FlexSetMon(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMonLevel(arg1:number):Promise<void>;
|
export function FlexSetMonLevel(arg1:number):Promise<void>;
|
||||||
@@ -202,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>;
|
||||||
@@ -210,14 +282,28 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetRIT(arg1:boolean):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 FlexSetTXSlice(arg1:number):Promise<void>;
|
export function FlexSetTXSlice(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetTunePower(arg1:number):Promise<void>;
|
export function FlexSetTunePower(arg1:number):Promise<void>;
|
||||||
@@ -228,12 +314,30 @@ export function FlexSetVoxDelay(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetVoxLevel(arg1:number):Promise<void>;
|
export function FlexSetVoxLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetWNB(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetWNBLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetXIT(arg1:boolean):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 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>;
|
||||||
@@ -252,6 +356,8 @@ export function GetAwardReferenceMeta():Promise<Array<main.AwardRefMeta>>;
|
|||||||
|
|
||||||
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
|
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
|
||||||
|
|
||||||
|
export function GetAwardUpdates():Promise<Array<main.AwardUpdate>>;
|
||||||
|
|
||||||
export function GetAwards():Promise<Array<award.Result>>;
|
export function GetAwards():Promise<Array<award.Result>>;
|
||||||
|
|
||||||
export function GetBackupSettings():Promise<main.BackupSettings>;
|
export function GetBackupSettings():Promise<main.BackupSettings>;
|
||||||
@@ -260,7 +366,9 @@ 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 GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
||||||
|
|
||||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||||
|
|
||||||
@@ -270,6 +378,8 @@ export function GetClusterAutoConnect():Promise<boolean>;
|
|||||||
|
|
||||||
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||||
|
|
||||||
|
export function GetContestRuns():Promise<Array<qso.ContestRun>>;
|
||||||
|
|
||||||
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
|
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
|
||||||
@@ -296,38 +406,62 @@ export function GetIcomState():Promise<cat.IcomTXState>;
|
|||||||
|
|
||||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||||
|
|
||||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
export function GetLiveStations():Promise<Array<main.LiveStation>>;
|
||||||
|
|
||||||
|
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||||
|
|
||||||
export function GetLogFilePath():Promise<string>;
|
export function GetLogFilePath():Promise<string>;
|
||||||
|
|
||||||
|
export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number,arg5:string):Promise<qso.Stats>;
|
||||||
|
|
||||||
export function GetLogbookRevision():Promise<string>;
|
export function GetLogbookRevision():Promise<string>;
|
||||||
|
|
||||||
export function GetLookupSettings():Promise<main.LookupSettings>;
|
export function GetLookupSettings():Promise<main.LookupSettings>;
|
||||||
|
|
||||||
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
||||||
|
|
||||||
|
export function GetOfflineStatus():Promise<main.OfflineStatus>;
|
||||||
|
|
||||||
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
||||||
|
|
||||||
|
export function GetOperators():Promise<Array<string>>;
|
||||||
|
|
||||||
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
||||||
|
|
||||||
export function GetPGXLStatus():Promise<powergenius.Status>;
|
export function GetPGXLStatus():Promise<powergenius.Status>;
|
||||||
|
|
||||||
export function GetPOTAToken():Promise<string>;
|
export function GetPOTAToken():Promise<string>;
|
||||||
|
|
||||||
|
export function GetPendingQSOs():Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||||
|
|
||||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||||
|
|
||||||
|
export function GetQSORate():Promise<main.QSORate>;
|
||||||
|
|
||||||
|
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
||||||
|
|
||||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
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 GetSolarData():Promise<solar.Data>;
|
||||||
|
|
||||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||||
|
|
||||||
|
export function GetStationDevices():Promise<Array<main.StationDevice>>;
|
||||||
|
|
||||||
export function GetStationSettings():Promise<main.StationSettings>;
|
export function GetStationSettings():Promise<main.StationSettings>;
|
||||||
|
|
||||||
|
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
||||||
|
|
||||||
export function GetTelemetryEnabled():Promise<boolean>;
|
export function GetTelemetryEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function GetUIPref(arg1:string):Promise<string>;
|
export function GetUIPref(arg1:string):Promise<string>;
|
||||||
@@ -336,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>;
|
||||||
@@ -434,6 +570,8 @@ export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<numbe
|
|||||||
|
|
||||||
export function ImportAwards():Promise<main.AwardImportResult>;
|
export function ImportAwards():Promise<main.AwardImportResult>;
|
||||||
|
|
||||||
|
export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
||||||
|
|
||||||
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
||||||
|
|
||||||
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||||
@@ -452,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>>;
|
||||||
@@ -466,10 +606,18 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
|
|||||||
|
|
||||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||||
|
|
||||||
|
export function LiveLastQSOAgeSec():Promise<number>;
|
||||||
|
|
||||||
|
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
||||||
|
|
||||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||||
|
|
||||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
|
export function MotorReadElements():Promise<Array<number>>;
|
||||||
|
|
||||||
|
export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function MoveDatabase(arg1:string):Promise<void>;
|
export function MoveDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function NetActivate(arg1:string):Promise<qso.QSO>;
|
export function NetActivate(arg1:string):Promise<qso.QSO>;
|
||||||
@@ -508,6 +656,8 @@ export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
|
|||||||
|
|
||||||
export function OpenADIFFile():Promise<string>;
|
export function OpenADIFFile():Promise<string>;
|
||||||
|
|
||||||
|
export function OpenAwardsFolder():Promise<void>;
|
||||||
|
|
||||||
export function OpenDatabase(arg1:string):Promise<void>;
|
export function OpenDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function OpenExternalURL(arg1:string):Promise<void>;
|
export function OpenExternalURL(arg1:string):Promise<void>;
|
||||||
@@ -516,6 +666,10 @@ 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 PickAudioFolder():Promise<string>;
|
export function PickAudioFolder():Promise<string>;
|
||||||
|
|
||||||
export function PickBackupFolder():Promise<string>;
|
export function PickBackupFolder():Promise<string>;
|
||||||
@@ -570,12 +724,18 @@ export function QSOAudioRestart():Promise<boolean>;
|
|||||||
|
|
||||||
export function QuitApp():Promise<void>;
|
export function QuitApp():Promise<void>;
|
||||||
|
|
||||||
|
export function RecomputeAllAwardRefs():Promise<number>;
|
||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
|
export function RefreshSolar():Promise<void>;
|
||||||
|
|
||||||
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
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>;
|
||||||
@@ -592,6 +752,8 @@ export function RestartApp():Promise<void>;
|
|||||||
|
|
||||||
export function RestartQSORecorder():Promise<void>;
|
export function RestartQSORecorder():Promise<void>;
|
||||||
|
|
||||||
|
export function RetryOfflineSync():Promise<number>;
|
||||||
|
|
||||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function RotatorPark():Promise<void>;
|
export function RotatorPark():Promise<void>;
|
||||||
@@ -600,10 +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 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>;
|
||||||
@@ -646,8 +818,12 @@ export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
|||||||
|
|
||||||
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||||
|
|
||||||
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||||
@@ -674,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>;
|
||||||
@@ -684,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>;
|
||||||
@@ -694,9 +866,7 @@ 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 StopCWDecoder():Promise<void>;
|
|
||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
@@ -722,8 +892,12 @@ 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 UltrabeamRetract():Promise<void>;
|
export function UltrabeamRetract():Promise<void>;
|
||||||
|
|
||||||
export function UnlockSecrets(arg1:string):Promise<void>;
|
export function UnlockSecrets(arg1:string):Promise<void>;
|
||||||
|
|||||||
+362
-22
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -26,14 +50,46 @@ export function AntGeniusDeselect(arg1) {
|
|||||||
return window['go']['main']['App']['AntGeniusDeselect'](arg1);
|
return window['go']['main']['App']['AntGeniusDeselect'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ApplyAwardImport(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['ApplyAwardImport'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function ApplyAwardPreset(arg1, arg2) {
|
export function ApplyAwardPreset(arg1, arg2) {
|
||||||
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ApplyAwardUpdate(arg1) {
|
||||||
|
return window['go']['main']['App']['ApplyAwardUpdate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
|
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AudioMonitorActive() {
|
||||||
|
return window['go']['main']['App']['AudioMonitorActive']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioStartMonitor() {
|
||||||
|
return window['go']['main']['App']['AudioStartMonitor']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioStartTX() {
|
||||||
|
return window['go']['main']['App']['AudioStartTX']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioStopMonitor() {
|
||||||
|
return window['go']['main']['App']['AudioStopMonitor']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioStopTX() {
|
||||||
|
return window['go']['main']['App']['AudioStopTX']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioTXActive() {
|
||||||
|
return window['go']['main']['App']['AudioTXActive']();
|
||||||
|
}
|
||||||
|
|
||||||
export function AwardCellQSOs(arg1, arg2, arg3) {
|
export function AwardCellQSOs(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
|
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
@@ -50,6 +106,14 @@ export function AwardRefsForQSOs(arg1) {
|
|||||||
return window['go']['main']['App']['AwardRefsForQSOs'](arg1);
|
return window['go']['main']['App']['AwardRefsForQSOs'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AwardsFolder() {
|
||||||
|
return window['go']['main']['App']['AwardsFolder']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackfillUSCounties() {
|
||||||
|
return window['go']['main']['App']['BackfillUSCounties']();
|
||||||
|
}
|
||||||
|
|
||||||
export function BrowseExecutable() {
|
export function BrowseExecutable() {
|
||||||
return window['go']['main']['App']['BrowseExecutable']();
|
return window['go']['main']['App']['BrowseExecutable']();
|
||||||
}
|
}
|
||||||
@@ -62,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']();
|
||||||
}
|
}
|
||||||
@@ -202,10 +262,18 @@ export function DiscoverFlexRadios() {
|
|||||||
return window['go']['main']['App']['DiscoverFlexRadios']();
|
return window['go']['main']['App']['DiscoverFlexRadios']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DismissAwardUpdate(arg1) {
|
||||||
|
return window['go']['main']['App']['DismissAwardUpdate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DownloadAllReferenceLists() {
|
export function DownloadAllReferenceLists() {
|
||||||
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadAndApplyUpdate(arg1) {
|
||||||
|
return window['go']['main']['App']['DownloadAndApplyUpdate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DownloadClublogCty() {
|
export function DownloadClublogCty() {
|
||||||
return window['go']['main']['App']['DownloadClublogCty']();
|
return window['go']['main']['App']['DownloadClublogCty']();
|
||||||
}
|
}
|
||||||
@@ -214,10 +282,22 @@ export function DownloadConfirmations(arg1, arg2, arg3) {
|
|||||||
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
|
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadLoTWUsers() {
|
||||||
|
return window['go']['main']['App']['DownloadLoTWUsers']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadULSCounties() {
|
||||||
|
return window['go']['main']['App']['DownloadULSCounties']();
|
||||||
|
}
|
||||||
|
|
||||||
export function DuplicateProfile(arg1, arg2) {
|
export function DuplicateProfile(arg1, arg2) {
|
||||||
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ExplainAward(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function ExportADIF(arg1, arg2) {
|
export function ExportADIF(arg1, arg2) {
|
||||||
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
|
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -230,6 +310,14 @@ export function ExportADIFSelected(arg1, arg2, arg3) {
|
|||||||
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
|
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function 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']();
|
||||||
}
|
}
|
||||||
@@ -274,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);
|
||||||
}
|
}
|
||||||
@@ -294,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);
|
||||||
}
|
}
|
||||||
@@ -334,14 +434,42 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetMicProfile(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetMicProfile'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetMon(arg1) {
|
export function FlexSetMon(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetMon'](arg1);
|
return window['go']['main']['App']['FlexSetMon'](arg1);
|
||||||
}
|
}
|
||||||
@@ -366,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);
|
||||||
}
|
}
|
||||||
@@ -382,6 +518,18 @@ export function FlexSetProcessorLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
|
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetRIT(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetRIT'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function 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);
|
||||||
}
|
}
|
||||||
@@ -390,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);
|
||||||
}
|
}
|
||||||
@@ -398,6 +554,14 @@ 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) {
|
||||||
|
return window['go']['main']['App']['FlexSetTXFilter'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetTXSlice(arg1) {
|
export function FlexSetTXSlice(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetTXSlice'](arg1);
|
return window['go']['main']['App']['FlexSetTXSlice'](arg1);
|
||||||
}
|
}
|
||||||
@@ -418,10 +582,38 @@ export function FlexSetVoxLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetVoxLevel'](arg1);
|
return window['go']['main']['App']['FlexSetVoxLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetWNB(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetWNB'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetWNBLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetWNBLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetXIT(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetXIT'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function 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() {
|
||||||
|
return window['go']['main']['App']['GetADIFMonitor']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetActiveProfile() {
|
export function GetActiveProfile() {
|
||||||
return window['go']['main']['App']['GetActiveProfile']();
|
return window['go']['main']['App']['GetActiveProfile']();
|
||||||
}
|
}
|
||||||
@@ -430,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']();
|
||||||
}
|
}
|
||||||
@@ -466,6 +666,10 @@ export function GetAwardStats(arg1) {
|
|||||||
return window['go']['main']['App']['GetAwardStats'](arg1);
|
return window['go']['main']['App']['GetAwardStats'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAwardUpdates() {
|
||||||
|
return window['go']['main']['App']['GetAwardUpdates']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAwards() {
|
export function GetAwards() {
|
||||||
return window['go']['main']['App']['GetAwards']();
|
return window['go']['main']['App']['GetAwards']();
|
||||||
}
|
}
|
||||||
@@ -482,8 +686,12 @@ export function GetCATState() {
|
|||||||
return window['go']['main']['App']['GetCATState']();
|
return window['go']['main']['App']['GetCATState']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetCWDecoderPitch() {
|
export function GetCatalogCodes() {
|
||||||
return window['go']['main']['App']['GetCWDecoderPitch']();
|
return window['go']['main']['App']['GetCatalogCodes']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetChangelog() {
|
||||||
|
return window['go']['main']['App']['GetChangelog']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetChatHistory(arg1) {
|
export function GetChatHistory(arg1) {
|
||||||
@@ -502,6 +710,10 @@ export function GetClusterStatus() {
|
|||||||
return window['go']['main']['App']['GetClusterStatus']();
|
return window['go']['main']['App']['GetClusterStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetContestRuns() {
|
||||||
|
return window['go']['main']['App']['GetContestRuns']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetCtyDatInfo() {
|
export function GetCtyDatInfo() {
|
||||||
return window['go']['main']['App']['GetCtyDatInfo']();
|
return window['go']['main']['App']['GetCtyDatInfo']();
|
||||||
}
|
}
|
||||||
@@ -554,14 +766,22 @@ export function GetListsSettings() {
|
|||||||
return window['go']['main']['App']['GetListsSettings']();
|
return window['go']['main']['App']['GetListsSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetLiveStatusEnabled() {
|
export function GetLiveStations() {
|
||||||
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
return window['go']['main']['App']['GetLiveStations']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetLoTWUsersStatus() {
|
||||||
|
return window['go']['main']['App']['GetLoTWUsersStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetLogFilePath() {
|
export function GetLogFilePath() {
|
||||||
return window['go']['main']['App']['GetLogFilePath']();
|
return window['go']['main']['App']['GetLogFilePath']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLogStats(arg1, arg2, arg3, arg4, arg5) {
|
||||||
|
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4, arg5);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetLogbookRevision() {
|
export function GetLogbookRevision() {
|
||||||
return window['go']['main']['App']['GetLogbookRevision']();
|
return window['go']['main']['App']['GetLogbookRevision']();
|
||||||
}
|
}
|
||||||
@@ -574,10 +794,18 @@ export function GetMySQLSettings() {
|
|||||||
return window['go']['main']['App']['GetMySQLSettings']();
|
return window['go']['main']['App']['GetMySQLSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetOfflineStatus() {
|
||||||
|
return window['go']['main']['App']['GetOfflineStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetOnlineOperators() {
|
export function GetOnlineOperators() {
|
||||||
return window['go']['main']['App']['GetOnlineOperators']();
|
return window['go']['main']['App']['GetOnlineOperators']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetOperators() {
|
||||||
|
return window['go']['main']['App']['GetOperators']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPGXLSettings() {
|
export function GetPGXLSettings() {
|
||||||
return window['go']['main']['App']['GetPGXLSettings']();
|
return window['go']['main']['App']['GetPGXLSettings']();
|
||||||
}
|
}
|
||||||
@@ -590,6 +818,10 @@ export function GetPOTAToken() {
|
|||||||
return window['go']['main']['App']['GetPOTAToken']();
|
return window['go']['main']['App']['GetPOTAToken']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetPendingQSOs() {
|
||||||
|
return window['go']['main']['App']['GetPendingQSOs']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetQSLDefaults() {
|
export function GetQSLDefaults() {
|
||||||
return window['go']['main']['App']['GetQSLDefaults']();
|
return window['go']['main']['App']['GetQSLDefaults']();
|
||||||
}
|
}
|
||||||
@@ -598,6 +830,14 @@ export function GetQSO(arg1) {
|
|||||||
return window['go']['main']['App']['GetQSO'](arg1);
|
return window['go']['main']['App']['GetQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetQSORate() {
|
||||||
|
return window['go']['main']['App']['GetQSORate']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetRelayAuto() {
|
||||||
|
return window['go']['main']['App']['GetRelayAuto']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetRotatorHeading() {
|
export function GetRotatorHeading() {
|
||||||
return window['go']['main']['App']['GetRotatorHeading']();
|
return window['go']['main']['App']['GetRotatorHeading']();
|
||||||
}
|
}
|
||||||
@@ -606,18 +846,38 @@ 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']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSlotStats() {
|
||||||
|
return window['go']['main']['App']['GetSlotStats']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSolarData() {
|
||||||
|
return window['go']['main']['App']['GetSolarData']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetStartupStatus() {
|
export function GetStartupStatus() {
|
||||||
return window['go']['main']['App']['GetStartupStatus']();
|
return window['go']['main']['App']['GetStartupStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetStationDevices() {
|
||||||
|
return window['go']['main']['App']['GetStationDevices']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetStationSettings() {
|
export function GetStationSettings() {
|
||||||
return window['go']['main']['App']['GetStationSettings']();
|
return window['go']['main']['App']['GetStationSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetStationStatus() {
|
||||||
|
return window['go']['main']['App']['GetStationStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetTelemetryEnabled() {
|
export function GetTelemetryEnabled() {
|
||||||
return window['go']['main']['App']['GetTelemetryEnabled']();
|
return window['go']['main']['App']['GetTelemetryEnabled']();
|
||||||
}
|
}
|
||||||
@@ -634,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']();
|
||||||
}
|
}
|
||||||
@@ -830,6 +1094,10 @@ export function ImportAwards() {
|
|||||||
return window['go']['main']['App']['ImportAwards']();
|
return window['go']['main']['App']['ImportAwards']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function InspectAwardImport() {
|
||||||
|
return window['go']['main']['App']['InspectAwardImport']();
|
||||||
|
}
|
||||||
|
|
||||||
export function LaunchAutostartProgram(arg1) {
|
export function LaunchAutostartProgram(arg1) {
|
||||||
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
||||||
}
|
}
|
||||||
@@ -866,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']();
|
||||||
}
|
}
|
||||||
@@ -894,6 +1166,14 @@ export function ListUDPIntegrations() {
|
|||||||
return window['go']['main']['App']['ListUDPIntegrations']();
|
return window['go']['main']['App']['ListUDPIntegrations']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LiveLastQSOAgeSec() {
|
||||||
|
return window['go']['main']['App']['LiveLastQSOAgeSec']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoTWUserInfo(arg1) {
|
||||||
|
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function LogUDPLoggedADIF(arg1) {
|
export function LogUDPLoggedADIF(arg1) {
|
||||||
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
|
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
|
||||||
}
|
}
|
||||||
@@ -902,6 +1182,14 @@ export function LookupCallsign(arg1) {
|
|||||||
return window['go']['main']['App']['LookupCallsign'](arg1);
|
return window['go']['main']['App']['LookupCallsign'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function MotorReadElements() {
|
||||||
|
return window['go']['main']['App']['MotorReadElements']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MotorSetElement(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['MotorSetElement'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function MoveDatabase(arg1) {
|
export function MoveDatabase(arg1) {
|
||||||
return window['go']['main']['App']['MoveDatabase'](arg1);
|
return window['go']['main']['App']['MoveDatabase'](arg1);
|
||||||
}
|
}
|
||||||
@@ -978,6 +1266,10 @@ export function OpenADIFFile() {
|
|||||||
return window['go']['main']['App']['OpenADIFFile']();
|
return window['go']['main']['App']['OpenADIFFile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function OpenAwardsFolder() {
|
||||||
|
return window['go']['main']['App']['OpenAwardsFolder']();
|
||||||
|
}
|
||||||
|
|
||||||
export function OpenDatabase(arg1) {
|
export function OpenDatabase(arg1) {
|
||||||
return window['go']['main']['App']['OpenDatabase'](arg1);
|
return window['go']['main']['App']['OpenDatabase'](arg1);
|
||||||
}
|
}
|
||||||
@@ -994,6 +1286,14 @@ 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() {
|
||||||
|
return window['go']['main']['App']['PickADIFMonitorFile']();
|
||||||
|
}
|
||||||
|
|
||||||
export function PickAudioFolder() {
|
export function PickAudioFolder() {
|
||||||
return window['go']['main']['App']['PickAudioFolder']();
|
return window['go']['main']['App']['PickAudioFolder']();
|
||||||
}
|
}
|
||||||
@@ -1102,10 +1402,18 @@ export function QuitApp() {
|
|||||||
return window['go']['main']['App']['QuitApp']();
|
return window['go']['main']['App']['QuitApp']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RecomputeAllAwardRefs() {
|
||||||
|
return window['go']['main']['App']['RecomputeAllAwardRefs']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RefreshCtyDat() {
|
export function RefreshCtyDat() {
|
||||||
return window['go']['main']['App']['RefreshCtyDat']();
|
return window['go']['main']['App']['RefreshCtyDat']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RefreshSolar() {
|
||||||
|
return window['go']['main']['App']['RefreshSolar']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ReloadUDPIntegrations() {
|
export function ReloadUDPIntegrations() {
|
||||||
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
||||||
}
|
}
|
||||||
@@ -1114,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);
|
||||||
}
|
}
|
||||||
@@ -1146,6 +1458,10 @@ export function RestartQSORecorder() {
|
|||||||
return window['go']['main']['App']['RestartQSORecorder']();
|
return window['go']['main']['App']['RestartQSORecorder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RetryOfflineSync() {
|
||||||
|
return window['go']['main']['App']['RetryOfflineSync']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RotatorGoTo(arg1, arg2) {
|
export function RotatorGoTo(arg1, arg2) {
|
||||||
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -1162,14 +1478,34 @@ 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']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveADIFMonitor(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveADIFMonitor'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveAlertRule(arg1) {
|
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);
|
||||||
}
|
}
|
||||||
@@ -1254,10 +1590,18 @@ export function SaveQSLDefaults(arg1) {
|
|||||||
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveRelayAuto(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1) {
|
export function SaveRotatorSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveStationDevices(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveStationDevices'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveStationSettings(arg1) {
|
export function SaveStationSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1310,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);
|
||||||
}
|
}
|
||||||
@@ -1330,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);
|
||||||
}
|
}
|
||||||
@@ -1350,12 +1686,8 @@ export function SetUltrabeamDirection(arg1) {
|
|||||||
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StartCWDecoder() {
|
export function StationSetRelay(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['StartCWDecoder']();
|
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) {
|
||||||
@@ -1406,10 +1738,18 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ULSStatus() {
|
||||||
|
return window['go']['main']['App']['ULSStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function UltrabeamRetract() {
|
export function UltrabeamRetract() {
|
||||||
return window['go']['main']['App']['UltrabeamRetract']();
|
return window['go']['main']['App']['UltrabeamRetract']();
|
||||||
}
|
}
|
||||||
|
|||||||
+1096
-4
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
@@ -120,6 +120,18 @@ func SingleRecordADIF(q qso.QSO) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras —
|
||||||
|
// so it can be written out and read back with nothing dropped. Used by the
|
||||||
|
// offline queue: a QSO parked in the safety file must come back identical
|
||||||
|
// (extras carry its queue id, awards refs, ADIF 3.1.7 leftovers…).
|
||||||
|
func FullRecordADIF(q qso.QSO) string {
|
||||||
|
var b strings.Builder
|
||||||
|
bw := bufio.NewWriter(&b)
|
||||||
|
writeRecord(bw, q, true)
|
||||||
|
bw.Flush()
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
|
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
|
||||||
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
|
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
|
||||||
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
|
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
|
||||||
|
|||||||
@@ -32,9 +32,13 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Antenna is one configured antenna (index + name as stored on the device).
|
// Antenna is one configured antenna (index + name as stored on the device).
|
||||||
|
// Bands is the device's band bitmask for the antenna (which bands it covers);
|
||||||
|
// 0 = unknown/all. The UI uses it to show only band-appropriate antennas, like
|
||||||
|
// the native 4O3A app.
|
||||||
type Antenna struct {
|
type Antenna struct {
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Bands int `json:"bands"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status is the snapshot the UI renders.
|
// Status is the snapshot the UI renders.
|
||||||
@@ -65,6 +69,8 @@ type Client struct {
|
|||||||
statusMu sync.RWMutex
|
statusMu sync.RWMutex
|
||||||
status Status
|
status Status
|
||||||
antennas map[int]string // index → name (rebuilt into status.Antennas)
|
antennas map[int]string // index → name (rebuilt into status.Antennas)
|
||||||
|
antBands map[int]int // index → band bitmask (which bands the antenna covers)
|
||||||
|
antRawN int // one-shot: how many raw antenna lines we've logged
|
||||||
|
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
running bool
|
running bool
|
||||||
@@ -80,6 +86,7 @@ func New(host string, port int, password string) *Client {
|
|||||||
password: strings.TrimSpace(password),
|
password: strings.TrimSpace(password),
|
||||||
stop: make(chan struct{}),
|
stop: make(chan struct{}),
|
||||||
antennas: map[int]string{},
|
antennas: map[int]string{},
|
||||||
|
antBands: map[int]int{},
|
||||||
status: Status{Host: host},
|
status: Status{Host: host},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,13 +363,24 @@ func (c *Client) parseAntenna(msg string) {
|
|||||||
// The device stores spaces as underscores in names.
|
// The device stores spaces as underscores in names.
|
||||||
name = strings.TrimSpace(strings.ReplaceAll(name, "_", " "))
|
name = strings.TrimSpace(strings.ReplaceAll(name, "_", " "))
|
||||||
}
|
}
|
||||||
|
// Band bitmask: which bands this antenna is configured for. The device sends
|
||||||
|
// it as "band=<mask>" (a bitmask). Kept so the UI can show only band-relevant
|
||||||
|
// antennas (like the native app). Log the first few raw antenna lines so the
|
||||||
|
// exact field name + bit values can be confirmed on real hardware.
|
||||||
|
bands := kvInt(msg, "band")
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
|
if c.antRawN < 8 {
|
||||||
|
c.antRawN++
|
||||||
|
applog.Printf("antgenius: antenna raw #%d: %q (parsed band mask=%d/0x%X)", c.antRawN, msg, bands, bands)
|
||||||
|
}
|
||||||
if name != "" && !isPlaceholderName(name) {
|
if name != "" && !isPlaceholderName(name) {
|
||||||
c.antennas[id] = name
|
c.antennas[id] = name
|
||||||
|
c.antBands[id] = bands
|
||||||
} else {
|
} else {
|
||||||
delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown
|
delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown
|
||||||
|
delete(c.antBands, id)
|
||||||
}
|
}
|
||||||
c.status.Antennas = sortedAntennas(c.antennas)
|
c.status.Antennas = sortedAntennas(c.antennas, c.antBands)
|
||||||
c.status.Connected = true
|
c.status.Connected = true
|
||||||
c.statusMu.Unlock()
|
c.statusMu.Unlock()
|
||||||
}
|
}
|
||||||
@@ -431,10 +449,10 @@ func isPlaceholderName(name string) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sortedAntennas(m map[int]string) []Antenna {
|
func sortedAntennas(m map[int]string, bands map[int]int) []Antenna {
|
||||||
out := make([]Antenna, 0, len(m))
|
out := make([]Antenna, 0, len(m))
|
||||||
for idx, name := range m {
|
for idx, name := range m {
|
||||||
out = append(out, Antenna{Index: idx, Name: name})
|
out = append(out, Antenna{Index: idx, Name: name, Bands: bands[idx]})
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index })
|
sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index })
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Codec converts between the wire payload of a network audio stream (Icom 50003)
|
||||||
|
// and OpsLog's internal PCM (16 kHz mono 16-bit little-endian — the format the
|
||||||
|
// capture/render engine and the pcmRing use). It exists so the transport code
|
||||||
|
// (icomaudio.go) never hard-codes a format: today PCM is an identity passthrough;
|
||||||
|
// tomorrow an Opus codec implements the same two methods and drops in unchanged.
|
||||||
|
// This mirrors how civTransport abstracts the CAT byte stream from its transport.
|
||||||
|
type Codec interface {
|
||||||
|
// Name is a short label for logs/UI.
|
||||||
|
Name() string
|
||||||
|
// Decode turns one received audio payload into internal PCM. The returned
|
||||||
|
// slice is freshly allocated (the caller may retain it).
|
||||||
|
Decode(payload []byte) ([]byte, error)
|
||||||
|
// Encode turns internal PCM into a payload to transmit.
|
||||||
|
Encode(pcm []byte) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pcm16Codec is the uncompressed 16-bit-PCM codec — the Icom "uncompressed"
|
||||||
|
// audio mode (rxcodec/txcodec in the conninfo). Icom sends little-endian 16-bit
|
||||||
|
// mono samples, which is byte-for-byte OpsLog's internal format, so decode/encode
|
||||||
|
// are copies. It is the Phase-4/5 default: zero-dependency, lossless, ideal on a
|
||||||
|
// LAN. Opus (for WAN/internet bandwidth) becomes another Codec later.
|
||||||
|
//
|
||||||
|
// NOTE: rate conversion is deliberately NOT this layer's job. The Icom RX audio
|
||||||
|
// is 16 kHz = our internal rate, so RX needs none. The rig's TX side may run at a
|
||||||
|
// different rate (the captured conninfo showed 8 kHz) — Phase 5 will resample in
|
||||||
|
// the TX path before Encode; keeping the codec rate-agnostic keeps that concern
|
||||||
|
// in one place.
|
||||||
|
type pcm16Codec struct{}
|
||||||
|
|
||||||
|
// NewPCM16Codec returns the uncompressed 16-bit PCM codec.
|
||||||
|
func NewPCM16Codec() Codec { return pcm16Codec{} }
|
||||||
|
|
||||||
|
func (pcm16Codec) Name() string { return "pcm16" }
|
||||||
|
|
||||||
|
func (pcm16Codec) Decode(payload []byte) ([]byte, error) {
|
||||||
|
// Icom PCM payloads are whole 16-bit samples; an odd length means a truncated
|
||||||
|
// packet — trim the stray byte rather than emit a half-sample click.
|
||||||
|
n := len(payload) &^ 1
|
||||||
|
if n != len(payload) {
|
||||||
|
if n == 0 {
|
||||||
|
return nil, fmt.Errorf("pcm16: payload too short (%d bytes)", len(payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]byte, n)
|
||||||
|
copy(out, payload[:n])
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pcm16Codec) Encode(pcm []byte) ([]byte, error) {
|
||||||
|
out := make([]byte, len(pcm))
|
||||||
|
copy(out, pcm)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPCM16CodecRoundTrip(t *testing.T) {
|
||||||
|
c := NewPCM16Codec()
|
||||||
|
in := []byte{0x01, 0x02, 0x03, 0x04, 0xff, 0x7f}
|
||||||
|
enc, err := c.Encode(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode: %v", err)
|
||||||
|
}
|
||||||
|
dec, err := c.Decode(enc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if string(dec) != string(in) {
|
||||||
|
t.Fatalf("round-trip mismatch: got % X want % X", dec, in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPCM16CodecTrimsOddByte(t *testing.T) {
|
||||||
|
c := NewPCM16Codec()
|
||||||
|
dec, err := c.Decode([]byte{0x10, 0x20, 0x30}) // 3 bytes = 1 sample + stray
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(dec) != 2 {
|
||||||
|
t.Fatalf("expected the stray byte trimmed to 2, got %d", len(dec))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPCM16CodecRejectsSingleByte(t *testing.T) {
|
||||||
|
c := NewPCM16Codec()
|
||||||
|
if _, err := c.Decode([]byte{0x10}); err == nil {
|
||||||
|
t.Fatalf("expected an error for a sub-sample payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ package audio
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
@@ -269,3 +270,151 @@ func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct
|
|||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pcmRing is a thread-safe, latency-bounded FIFO of PCM bytes feeding a live
|
||||||
|
// render stream. Producers (a USB-codec capture, or a decoded network audio
|
||||||
|
// stream) Push freshly-arrived samples; the render loop Pulls. It is the shared
|
||||||
|
// hand-off point between "where the audio comes from" (USB device / UDP 50003)
|
||||||
|
// and "where it's heard" (any WASAPI output) — so the transport can be swapped
|
||||||
|
// without touching the render side, mirroring the civTransport split on the CAT
|
||||||
|
// side. On overflow the oldest audio is dropped to keep latency bounded; on
|
||||||
|
// underrun Pull simply returns short and the render loop pads with silence.
|
||||||
|
type pcmRing struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf []byte
|
||||||
|
max int // hard cap in bytes (drops oldest beyond this → bounded latency)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPCMRing makes a ring whose backlog is capped at maxBytes. Size it from the
|
||||||
|
// acceptable latency: bytesPerSec (=32000) worth ≈ 1 s.
|
||||||
|
func newPCMRing(maxBytes int) *pcmRing {
|
||||||
|
if maxBytes <= 0 {
|
||||||
|
maxBytes = bytesPerSec // 1 s default
|
||||||
|
}
|
||||||
|
return &pcmRing{max: maxBytes}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push appends samples, dropping the oldest audio if the backlog would exceed
|
||||||
|
// the cap (a slow/absent consumer never makes the producer block or grow without
|
||||||
|
// bound). A short glitch beats runaway latency for live monitoring.
|
||||||
|
func (r *pcmRing) Push(p []byte) {
|
||||||
|
if len(p) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.buf = append(r.buf, p...)
|
||||||
|
if len(r.buf) > r.max {
|
||||||
|
drop := len(r.buf) - r.max
|
||||||
|
r.buf = append(r.buf[:0], r.buf[drop:]...)
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pull removes and returns up to maxBytes of queued PCM (a private copy), or nil
|
||||||
|
// when empty. The render loop pads any shortfall with silence.
|
||||||
|
func (r *pcmRing) pull(maxBytes int) []byte {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if len(r.buf) == 0 || maxBytes <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n := maxBytes
|
||||||
|
if n > len(r.buf) {
|
||||||
|
n = len(r.buf)
|
||||||
|
}
|
||||||
|
out := make([]byte, n)
|
||||||
|
copy(out, r.buf[:n])
|
||||||
|
r.buf = append(r.buf[:0], r.buf[n:]...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderStream continuously renders PCM pulled from src to a device until stop
|
||||||
|
// closes — the streaming counterpart to playPCM's fixed buffer. On underrun it
|
||||||
|
// writes silence rather than glitching, keeping the WASAPI clock steady so live
|
||||||
|
// monitor audio flows smoothly even when the source stalls briefly. Runs on a
|
||||||
|
// COM-initialised, OS-locked thread.
|
||||||
|
func renderStream(deviceID string, rate, ch, bits int, stop <-chan struct{}, src *pcmRing) error {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
if err := coInit(); err != nil {
|
||||||
|
return fmt.Errorf("CoInitialize: %w", err)
|
||||||
|
}
|
||||||
|
defer ole.CoUninitialize()
|
||||||
|
|
||||||
|
dev, err := openDevice(wca.ERender, deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dev.Release()
|
||||||
|
|
||||||
|
var ac *wca.IAudioClient
|
||||||
|
if err := dev.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &ac); err != nil {
|
||||||
|
return fmt.Errorf("activate render: %w", err)
|
||||||
|
}
|
||||||
|
defer ac.Release()
|
||||||
|
|
||||||
|
frameBytes := ch * bits / 8
|
||||||
|
if frameBytes <= 0 {
|
||||||
|
return fmt.Errorf("bad audio format")
|
||||||
|
}
|
||||||
|
wfx := &wca.WAVEFORMATEX{
|
||||||
|
WFormatTag: 1, NChannels: uint16(ch), NSamplesPerSec: uint32(rate),
|
||||||
|
NAvgBytesPerSec: uint32(rate * frameBytes), NBlockAlign: uint16(frameBytes),
|
||||||
|
WBitsPerSample: uint16(bits), CbSize: 0,
|
||||||
|
}
|
||||||
|
if err := ac.Initialize(wca.AUDCLNT_SHAREMODE_SHARED, autoConvert,
|
||||||
|
wca.REFERENCE_TIME(bufferDuration100ns), 0, wfx, nil); err != nil {
|
||||||
|
return fmt.Errorf("initialize render: %w", err)
|
||||||
|
}
|
||||||
|
var bufFrames uint32
|
||||||
|
if err := ac.GetBufferSize(&bufFrames); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var arc *wca.IAudioRenderClient
|
||||||
|
if err := ac.GetService(wca.IID_IAudioRenderClient, &arc); err != nil {
|
||||||
|
return fmt.Errorf("get render service: %w", err)
|
||||||
|
}
|
||||||
|
defer arc.Release()
|
||||||
|
|
||||||
|
// feed fills up to `frames` render frames: as much real audio as the ring
|
||||||
|
// has, the remainder silence (so the buffer stays full and the clock steady).
|
||||||
|
feed := func(frames int) error {
|
||||||
|
if frames <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var data *byte
|
||||||
|
if err := arc.GetBuffer(uint32(frames), &data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dst := unsafe.Slice(data, frames*frameBytes)
|
||||||
|
got := src.pull(frames * frameBytes)
|
||||||
|
n := copy(dst, got)
|
||||||
|
for i := n; i < len(dst); i++ {
|
||||||
|
dst[i] = 0 // silence-fill the shortfall
|
||||||
|
}
|
||||||
|
arc.ReleaseBuffer(uint32(frames), 0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := feed(int(bufFrames)); err != nil { // pre-fill to avoid a start glitch
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ac.Start(); err != nil {
|
||||||
|
return fmt.Errorf("start render: %w", err)
|
||||||
|
}
|
||||||
|
defer ac.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
var padding uint32
|
||||||
|
ac.GetCurrentPadding(&padding)
|
||||||
|
if err := feed(int(bufFrames - padding)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(8 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+131
-1
@@ -15,7 +15,10 @@ type Manager struct {
|
|||||||
recStop chan struct{}
|
recStop chan struct{}
|
||||||
recDone chan recResult
|
recDone chan recResult
|
||||||
playStop chan struct{}
|
playStop chan struct{}
|
||||||
onChange func() // fired on any record/playback state transition
|
monStop chan struct{} // RX monitor passthrough (capture → render)
|
||||||
|
monRing *pcmRing // live audio hand-off, also fed by the network stream
|
||||||
|
txStop chan struct{} // TX audio passthrough (mic → rig)
|
||||||
|
onChange func() // fired on any record/playback state transition
|
||||||
}
|
}
|
||||||
|
|
||||||
type recResult struct {
|
type recResult struct {
|
||||||
@@ -135,3 +138,130 @@ func (m *Manager) StopPlayback() {
|
|||||||
m.notify()
|
m.notify()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------
|
||||||
|
//
|
||||||
|
// StartMonitor pipes live RX audio from inputDev (e.g. the rig's "USB Audio
|
||||||
|
// CODEC" capture endpoint) to outputDev (your speakers/headset) through a
|
||||||
|
// latency-bounded ring, so you HEAR the radio inside OpsLog. The very same ring
|
||||||
|
// is later fed by the network 50003 stream instead of a USB capture — the render
|
||||||
|
// half is transport-agnostic. inputDev "" = system default capture.
|
||||||
|
func (m *Manager) StartMonitor(inputDev, outputDev string) error {
|
||||||
|
return m.startMonitor(inputDev, outputDev, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartMonitorSink starts ONLY the render side (no USB capture) so an external
|
||||||
|
// producer — the network 50003 stream — can feed decoded RX PCM via
|
||||||
|
// PushMonitorAudio. Same output path as StartMonitor, minus the capture goroutine.
|
||||||
|
func (m *Manager) StartMonitorSink(outputDev string) error {
|
||||||
|
return m.startMonitor("", outputDev, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startMonitor wires the RX monitor: always a render loop pulling from monRing;
|
||||||
|
// when capture is true it also captures inputDev into that ring (USB monitor).
|
||||||
|
// When false the ring is fed only by PushMonitorAudio (network audio).
|
||||||
|
func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.monStop != nil {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return fmt.Errorf("monitor already running")
|
||||||
|
}
|
||||||
|
stop := make(chan struct{})
|
||||||
|
ring := newPCMRing(bytesPerSec / 2) // ~500 ms cap — low latency for live monitor
|
||||||
|
m.monStop, m.monRing = stop, ring
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if capture {
|
||||||
|
// Producer: capture the rig's USB audio into the ring.
|
||||||
|
go func() {
|
||||||
|
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(chunk) })
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
// Consumer: render the ring to the output device at the internal 16 kHz mono.
|
||||||
|
go func() {
|
||||||
|
_ = renderStream(outputDev, sampleRate, channels, bitsPerSample, stop, ring)
|
||||||
|
}()
|
||||||
|
m.notify()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopMonitor stops the RX monitor passthrough.
|
||||||
|
func (m *Manager) StopMonitor() {
|
||||||
|
m.mu.Lock()
|
||||||
|
stop := m.monStop
|
||||||
|
m.monStop, m.monRing = nil, nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
if stop != nil {
|
||||||
|
close(stop)
|
||||||
|
m.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonitorActive reports whether the RX monitor passthrough is running.
|
||||||
|
func (m *Manager) MonitorActive() bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.monStop != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushMonitorAudio feeds externally-sourced PCM (16 kHz mono 16-bit) into the
|
||||||
|
// active monitor's output — the hook the network 50003 audio stream uses to play
|
||||||
|
// decoded RX through the very same output path a USB capture feeds. No-op when no
|
||||||
|
// monitor is running. Keeps the unexported ring inside the package.
|
||||||
|
func (m *Manager) PushMonitorAudio(pcm []byte) {
|
||||||
|
m.mu.Lock()
|
||||||
|
ring := m.monRing
|
||||||
|
m.mu.Unlock()
|
||||||
|
if ring != nil {
|
||||||
|
ring.Push(pcm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- TX audio passthrough (Phase 3: live mic → rig over USB) --------------
|
||||||
|
//
|
||||||
|
// StartTXAudio pipes your live microphone (micDev) into the rig's audio input
|
||||||
|
// (toRadioDev — for a USB-connected rig, its "USB Audio CODEC" render endpoint),
|
||||||
|
// so you talk through the PC. It is the mirror of StartMonitor (same ring +
|
||||||
|
// capture + render primitives, source/sink swapped). PTT keying is the caller's
|
||||||
|
// job (the app layer keys PTT before this and unkeys after) so this stays a pure
|
||||||
|
// audio route. The captured 16 kHz mono stream is also the exact shape the future
|
||||||
|
// network 50003 TX will encode and send — so Phase 5 reuses this capture side.
|
||||||
|
func (m *Manager) StartTXAudio(micDev, toRadioDev string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.txStop != nil {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return fmt.Errorf("TX audio already running")
|
||||||
|
}
|
||||||
|
stop := make(chan struct{})
|
||||||
|
ring := newPCMRing(bytesPerSec / 4) // ~250 ms — tighter for live TX latency
|
||||||
|
m.txStop = stop
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = captureStream(micDev, stop, func(chunk []byte) { ring.Push(chunk) })
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
_ = renderStream(toRadioDev, sampleRate, channels, bitsPerSample, stop, ring)
|
||||||
|
}()
|
||||||
|
m.notify()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopTXAudio stops the TX mic→rig passthrough.
|
||||||
|
func (m *Manager) StopTXAudio() {
|
||||||
|
m.mu.Lock()
|
||||||
|
stop := m.txStop
|
||||||
|
m.txStop = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
if stop != nil {
|
||||||
|
close(stop)
|
||||||
|
m.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TXAudioActive reports whether the TX mic→rig passthrough is running.
|
||||||
|
func (m *Manager) TXAudioActive() bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.txStop != nil
|
||||||
|
}
|
||||||
|
|||||||
+467
-90
@@ -13,6 +13,10 @@
|
|||||||
package award
|
package award
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -63,20 +67,25 @@ type Def struct {
|
|||||||
|
|
||||||
// --- Type & matching ---
|
// --- Type & matching ---
|
||||||
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
||||||
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
Field string `json:"field"` // QSO field to scan (see fieldRaw)
|
||||||
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
|
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
|
||||||
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
|
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
|
||||||
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
|
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
|
||||||
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
|
||||||
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
|
||||||
Multi bool `json:"multi,omitempty"` // a QSO may count for several references
|
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
||||||
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
// NOTE: there is no "one reference per QSO" switch, and there was never any
|
||||||
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
|
// point in one. A QSO ALWAYS yields every reference its field holds — an n-fer
|
||||||
|
// POTA activation ("US-6544,US-0680"), a VUCC contact on a grid line. The old
|
||||||
|
// `Multi` flag was read by nothing; its checkbox changed nothing.
|
||||||
|
|
||||||
// OrRules are ADDITIONAL searches OR'd with the primary one above: a QSO
|
// OrRules are ordered FALLBACK searches for the primary one above: they are
|
||||||
// earns a reference if the primary match OR any of these match. Lets a
|
// tried IN ORDER and only while nothing has matched yet — the first rule that
|
||||||
// French department (DDFM) be found from "D74" in the note AND from a postal
|
// yields a reference wins and the rest are skipped (short-circuit, like a
|
||||||
// code "74140" in the address (pattern captures "74", Prefix "D" → "D74").
|
// chain of "else if"). Lets a Chinese province (WAPC) be found first from the
|
||||||
|
// province NAME in the address, and only if that fails, from a city regex
|
||||||
|
// (e.g. "\bJiangyin\b" → JS) — so a QSO already resolved by name isn't also
|
||||||
|
// re-tagged, possibly differently, by a later rule.
|
||||||
OrRules []OrRule `json:"or_rules,omitempty"`
|
OrRules []OrRule `json:"or_rules,omitempty"`
|
||||||
|
|
||||||
// --- Scope ---
|
// --- Scope ---
|
||||||
@@ -88,11 +97,45 @@ type Def struct {
|
|||||||
// --- Confirmation ---
|
// --- Confirmation ---
|
||||||
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
||||||
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
||||||
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
|
// NOT IMPLEMENTED. Kept so the values operators already typed are not lost, but
|
||||||
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
|
// nothing reads them: no ADIF export has ever written CREDIT_GRANTED. Their
|
||||||
|
// controls have been removed from the editor — a checkbox that quietly does
|
||||||
|
// nothing is worse than no checkbox, because it is trusted. Wire these up (in
|
||||||
|
// internal/adif) before showing them again.
|
||||||
|
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
|
||||||
|
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
|
||||||
|
|
||||||
Total int `json:"total"` // known denominator (0 = unknown / derive from list)
|
Total int `json:"total"` // known denominator (0 = unknown / derive from list)
|
||||||
Builtin bool `json:"builtin"` // shipped default (informational)
|
Builtin bool `json:"builtin"` // shipped default (informational)
|
||||||
|
|
||||||
|
// --- Catalog updates ---
|
||||||
|
// Version is the revision of a SHIPPED award. Bump it in the catalog JSON when
|
||||||
|
// you fix a definition (a better OR chain, a corrected reference list) and want
|
||||||
|
// that fix to reach operators who already run the award: on startup, a catalog
|
||||||
|
// award whose Version is higher than the stored one REPLACES it, definition and
|
||||||
|
// references. Awards created by the operator have no version and are never touched.
|
||||||
|
Version int `json:"version,omitempty"`
|
||||||
|
// UserEdited marks an award the operator has changed. A catalog update then
|
||||||
|
// SKIPS it: their work outranks ours. Set the moment the award (or its reference
|
||||||
|
// list) is saved to something other than what the catalog ships.
|
||||||
|
UserEdited bool `json:"user_edited,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SameContent reports whether two definitions describe the same award — ignoring
|
||||||
|
// the bookkeeping fields (version, the user-edited flag, the derived builtin bit),
|
||||||
|
// which say where a definition came from, not what it does. Used to decide whether
|
||||||
|
// a save actually changed anything.
|
||||||
|
func (d Def) SameContent(o Def) bool {
|
||||||
|
a, b := d, o
|
||||||
|
a.Version, b.Version = 0, 0
|
||||||
|
a.UserEdited, b.UserEdited = false, false
|
||||||
|
a.Builtin, b.Builtin = false, false
|
||||||
|
ja, err1 := json.Marshal(a)
|
||||||
|
jb, err2 := json.Marshal(b)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return string(ja) == string(jb)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrRule is one additional search OR'd with the award's primary matching rule.
|
// OrRule is one additional search OR'd with the award's primary matching rule.
|
||||||
@@ -109,26 +152,124 @@ type OrRule struct {
|
|||||||
Prefix string `json:"prefix,omitempty"` // prepended to each found reference
|
Prefix string `json:"prefix,omitempty"` // prepended to each found reference
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults are the built-in awards seeded on first run (then user-editable).
|
// catalogFS holds the built-in award definitions as DATA, not Go code.
|
||||||
func Defaults() []Def {
|
//
|
||||||
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
|
// An award is data — a field to scan, a pattern, a scope. Coding it in Go meant a
|
||||||
// "electronically verified" tier: LoTW only — a paper QSL confirms but does
|
// recompile and a release for every new one, which is absurd for something that
|
||||||
// NOT validate (matches ARRL/Log4OM). eQSL counts only where the program
|
// changes far more often than the engine that reads it. They now live one JSON per
|
||||||
// accepts it (WAC).
|
// award in catalog/, embedded in the binary: adding an award is adding a file.
|
||||||
lq := []string{"lotw", "qsl"}
|
//
|
||||||
lo := []string{"lotw"}
|
// This is the SEED only. Once a user has awards in their database, that database
|
||||||
return []Def{
|
// is the source of truth — the catalog never overwrites their edits behind their
|
||||||
{Code: "DXCC", Name: "DX Century Club", Type: TypeDXCC, Field: "dxcc", Confirm: lq, Validate: lo, Total: 340, Valid: true, Builtin: true, Protected: true},
|
// back.
|
||||||
{Code: "WAS", Name: "Worked All States", Type: TypeQSOFields, Field: "state", MatchBy: "code", ExactMatch: true, DXCCFilter: []int{291, 110, 6}, Confirm: lq, Validate: lo, Total: 50, Valid: true, Builtin: true, Protected: true},
|
//
|
||||||
{Code: "WAZ", Name: "Worked All Zones (CQ)", Type: TypeQSOFields, Field: "cqz", MatchBy: "code", ExactMatch: true, Confirm: lq, Validate: lo, Total: 40, Valid: true, Builtin: true, Protected: true},
|
//go:embed catalog/*.json
|
||||||
{Code: "WAC", Name: "Worked All Continents", Type: TypeQSOFields, Field: "cont", MatchBy: "code", ExactMatch: true, Confirm: []string{"lotw", "qsl", "eqsl"}, Validate: lo, Total: 6, Valid: true, Builtin: true, Protected: true},
|
var catalogFS embed.FS
|
||||||
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Type: TypeQSOFields, Field: "prefix", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
|
||||||
{Code: "DDFM", Name: "Départements Français Métropolitains", Type: TypeQSOFields, Field: "note", Pattern: `(?i)\b(D\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: lq, Validate: lo, Total: 96, Valid: true, Builtin: true, Protected: true},
|
// CatalogEntry is one award in the catalog: its definition AND its reference list.
|
||||||
{Code: "IOTA", Name: "Islands On The Air", Type: TypeReference, Field: "iota", Dynamic: true, Confirm: []string{"qsl"}, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
//
|
||||||
{Code: "POTA", Name: "Parks On The Air", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
// The references are the point. An award's definition is a few lines; what makes
|
||||||
{Code: "SOTA", Name: "Summits On The Air", Type: TypeReference, Field: "sota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
// WAPC worth anything is its 34 provinces and the city regexes attached to them.
|
||||||
{Code: "WWFF", Name: "World Wide Flora & Fauna", Type: TypeReference, Field: "wwff", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
// A catalog that shipped definitions only would hand every user an empty shell —
|
||||||
|
// which is exactly the trap this design is meant to avoid.
|
||||||
|
//
|
||||||
|
// References stay as raw JSON so this package (the matching ENGINE) never has to
|
||||||
|
// import the reference-store package. The caller decodes them.
|
||||||
|
type CatalogEntry struct {
|
||||||
|
Def Def `json:"def"`
|
||||||
|
References json.RawMessage `json:"references,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogFile is what a file in catalog/ may contain. Two shapes are accepted:
|
||||||
|
//
|
||||||
|
// {"def": {...}, "references": [...]} — a catalog entry
|
||||||
|
// {"version":1, "awards":[{"def":…,"references":…}]} — an exported bundle
|
||||||
|
//
|
||||||
|
// Accepting the bundle shape is deliberate: it means an award EXPORTED from the UI
|
||||||
|
// can be dropped straight into catalog/ and shipped to everyone, with no
|
||||||
|
// conversion step. That is the whole loop — create an award, export it, drop it in,
|
||||||
|
// everybody has it.
|
||||||
|
type catalogFile struct {
|
||||||
|
Def Def `json:"def"`
|
||||||
|
References json.RawMessage `json:"references,omitempty"`
|
||||||
|
Awards []CatalogEntry `json:"awards,omitempty"`
|
||||||
|
// A bare Def (the original catalog shape) is still read via the fields above
|
||||||
|
// being empty and Code being set at the top level.
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catalog returns every built-in award: definition + references, sorted by code.
|
||||||
|
//
|
||||||
|
// Sorted because an embed.FS walk is alphabetical and relying on that implicitly is
|
||||||
|
// how a user's award list quietly reorders itself on a rebuild.
|
||||||
|
func Catalog() []CatalogEntry {
|
||||||
|
entries, err := catalogFS.ReadDir("catalog")
|
||||||
|
if err != nil {
|
||||||
|
return nil // embedded: can only fail if the build is broken
|
||||||
}
|
}
|
||||||
|
out := make([]CatalogEntry, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, err := catalogFS.ReadFile("catalog/" + e.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var f catalogFile
|
||||||
|
if err := json.Unmarshal(b, &f); err != nil {
|
||||||
|
// A malformed file must not take the other awards down with it.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(f.Awards) > 0: // an exported bundle, dropped in as-is
|
||||||
|
for _, a := range f.Awards {
|
||||||
|
if strings.TrimSpace(a.Def.Code) != "" {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case strings.TrimSpace(f.Def.Code) != "": // {"def":…,"references":…}
|
||||||
|
out = append(out, CatalogEntry{Def: f.Def, References: f.References})
|
||||||
|
case strings.TrimSpace(f.Code) != "": // a bare Def
|
||||||
|
var d Def
|
||||||
|
if err := json.Unmarshal(b, &d); err == nil {
|
||||||
|
out = append(out, CatalogEntry{Def: d})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An award OpsLog SHIPS is built-in, by definition. Derive it here instead of
|
||||||
|
// trusting the flag in the file: you drop in an award you exported, its JSON
|
||||||
|
// says builtin:false (you wrote it, it wasn't built-in then), and it would
|
||||||
|
// quietly miss every future catalog correction. Making the author remember to
|
||||||
|
// flip a flag is exactly the kind of step nobody can guess.
|
||||||
|
for i := range out {
|
||||||
|
out[i].Def.Builtin = true
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Def.Code < out[j].Def.Code })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults are the built-in award definitions seeded on first run.
|
||||||
|
func Defaults() []Def {
|
||||||
|
cat := Catalog()
|
||||||
|
out := make([]Def, 0, len(cat))
|
||||||
|
for _, e := range cat {
|
||||||
|
out = append(out, e.Def)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogRefs returns the raw JSON reference list a catalog award ships with, if
|
||||||
|
// any. Awards whose list is seeded from code (DXCC entities, French departments)
|
||||||
|
// or fetched online (POTA/SOTA/WWFF) carry none.
|
||||||
|
func CatalogRefs(code string) (json.RawMessage, bool) {
|
||||||
|
code = strings.ToUpper(strings.TrimSpace(code))
|
||||||
|
for _, e := range Catalog() {
|
||||||
|
if strings.ToUpper(e.Def.Code) == code && len(e.References) > 0 {
|
||||||
|
return e.References, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate upgrades award definitions saved before the richer model existed.
|
// Migrate upgrades award definitions saved before the richer model existed.
|
||||||
@@ -177,7 +318,7 @@ func Migrate(defs []Def) ([]Def, bool) {
|
|||||||
func Fields() []string {
|
func Fields() []string {
|
||||||
return []string{
|
return []string{
|
||||||
"dxcc", "cqz", "ituz", "prefix", "callsign",
|
"dxcc", "cqz", "ituz", "prefix", "callsign",
|
||||||
"state", "cont", "country", "grid", "grid4",
|
"state", "us_county", "cont", "country", "grid", "grid4",
|
||||||
"iota", "sota_ref", "pota_ref", "wwff",
|
"iota", "sota_ref", "pota_ref", "wwff",
|
||||||
"name", "qth", "address", "comment", "note",
|
"name", "qth", "address", "comment", "note",
|
||||||
}
|
}
|
||||||
@@ -257,6 +398,21 @@ type RefMeta struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewRefList builds the engine's reference view from (code, meta) pairs.
|
// NewRefList builds the engine's reference view from (code, meta) pairs.
|
||||||
|
// compileAwardRE compiles an award / reference regex. Award patterns are run
|
||||||
|
// over free-text log fields (QTH, NOTES) whose capitalization is arbitrary — a
|
||||||
|
// log may hold "Tokyo", "TOKYO" or "TOKIO" — so we match case-insensitively by
|
||||||
|
// default. An author who set their own flag group (e.g. "(?s)") is respected.
|
||||||
|
func compileAwardRE(p string) (*regexp.Regexp, error) {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
return nil, errors.New("empty pattern")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(p, "(?") {
|
||||||
|
p = "(?i)" + p
|
||||||
|
}
|
||||||
|
return regexp.Compile(p)
|
||||||
|
}
|
||||||
|
|
||||||
func NewRefList(metas []RefMeta) refList {
|
func NewRefList(metas []RefMeta) refList {
|
||||||
rl := refList{byCode: make(map[string]RefMeta, len(metas))}
|
rl := refList{byCode: make(map[string]RefMeta, len(metas))}
|
||||||
for _, m := range metas {
|
for _, m := range metas {
|
||||||
@@ -265,7 +421,7 @@ func NewRefList(metas []RefMeta) refList {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if p := strings.TrimSpace(m.Pattern); p != "" {
|
if p := strings.TrimSpace(m.Pattern); p != "" {
|
||||||
if re, err := regexp.Compile(p); err == nil {
|
if re, err := compileAwardRE(p); err == nil {
|
||||||
m.re = re
|
m.re = re
|
||||||
rl.withPattern = append(rl.withPattern, code)
|
rl.withPattern = append(rl.withPattern, code)
|
||||||
}
|
}
|
||||||
@@ -297,7 +453,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
|||||||
perr := make([]string, len(defs))
|
perr := make([]string, len(defs))
|
||||||
for i := range defs {
|
for i := range defs {
|
||||||
if p := strings.TrimSpace(defs[i].Pattern); p != "" {
|
if p := strings.TrimSpace(defs[i].Pattern); p != "" {
|
||||||
re, err := regexp.Compile(p)
|
re, err := compileAwardRE(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
perr[i] = "bad pattern: " + err.Error()
|
perr[i] = "bad pattern: " + err.Error()
|
||||||
} else {
|
} else {
|
||||||
@@ -432,24 +588,14 @@ func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
|
|||||||
}
|
}
|
||||||
var re *regexp.Regexp
|
var re *regexp.Regexp
|
||||||
if p := strings.TrimSpace(d.Pattern); p != "" {
|
if p := strings.TrimSpace(d.Pattern); p != "" {
|
||||||
if c, err := regexp.Compile(p); err == nil {
|
if c, err := compileAwardRE(p); err == nil {
|
||||||
re = c
|
re = c
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rl := NewRefList(metas)
|
rl := NewRefList(metas)
|
||||||
found := candidates(&d, re, q, rl, len(metas) > 0)
|
return candidates(&d, re, q, rl, len(metas) > 0)
|
||||||
// Merge operator-assigned references (manual override). These let the
|
|
||||||
// operator tag a QSO for an award whose field/description matching can't
|
|
||||||
// auto-detect the reference — e.g. WAPC scans the ADDRESS field for a
|
|
||||||
// province NAME, so a contact whose address doesn't spell it out needs the
|
|
||||||
// province picked by hand. For a predefined award the override is still
|
|
||||||
// validated against its reference list.
|
|
||||||
if man := manualRefs(q, d.Code); len(man) > 0 {
|
|
||||||
found = mergeManual(found, man, rl, len(metas) > 0 && !d.Dynamic)
|
|
||||||
}
|
|
||||||
return found
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
|
// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
|
||||||
@@ -484,30 +630,6 @@ func manualRefs(q *qso.QSO, code string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeManual appends operator-assigned codes to the auto-found set, deduped.
|
|
||||||
// When the award is predefined, only references present and valid in its list
|
|
||||||
// are kept (so a typo can't invent a reference).
|
|
||||||
func mergeManual(found, manual []string, rl refList, predefined bool) []string {
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
for _, c := range found {
|
|
||||||
seen[normalizeRef(c)] = struct{}{}
|
|
||||||
}
|
|
||||||
for _, c := range manual {
|
|
||||||
c = normalizeRef(c)
|
|
||||||
if _, dup := seen[c]; dup {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if predefined {
|
|
||||||
if m, ok := rl.byCode[c]; !ok || !m.Valid {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen[c] = struct{}{}
|
|
||||||
found = append(found, c)
|
|
||||||
}
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirmed reports whether a QSO satisfies any of the given confirmation
|
// Confirmed reports whether a QSO satisfies any of the given confirmation
|
||||||
// sources (lotw|qsl|eqsl). Exported for the statistics view.
|
// sources (lotw|qsl|eqsl). Exported for the statistics view.
|
||||||
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
|
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
|
||||||
@@ -521,6 +643,63 @@ func InScope(d Def, q *qso.QSO) bool { return inScope(&d, q) }
|
|||||||
// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
|
// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
|
||||||
func EmissionOf(mode string) string { return emissionOf(mode) }
|
func EmissionOf(mode string) string { return emissionOf(mode) }
|
||||||
|
|
||||||
|
// USCountyKey normalises a QSO's state + cnty into the canonical "STATE,COUNTY"
|
||||||
|
// match code used by the US Counties (USA-CA) award, so the reference list (in
|
||||||
|
// that same form) matches whatever shape the logbook holds. It is the SINGLE
|
||||||
|
// source of truth: cmd/cntygen builds the reference codes by calling it, so the
|
||||||
|
// two sides can never drift.
|
||||||
|
//
|
||||||
|
// Real logs are a mess — "MA,MIDDLESEX", bare "Middlesex" with the state in its
|
||||||
|
// own column, mixed case, county-type suffixes, the odd "0", plus the FIPS list
|
||||||
|
// abbreviating "Saint"→"St." and hyphenating "Matanuska-Susitna". The rules:
|
||||||
|
// - if cnty already carries "ST,County", split on the first comma; else take
|
||||||
|
// the state from the STATE column;
|
||||||
|
// - upper-case; drop periods and apostrophes; hyphens→space; strip a trailing
|
||||||
|
// County/Parish/Borough/Census Area/Municipality; fold Saint(e)→St(e);
|
||||||
|
// collapse whitespace;
|
||||||
|
// - require a 2-letter state and a non-empty county, else no match ("").
|
||||||
|
func USCountyKey(state, cnty string) string {
|
||||||
|
s := strings.TrimSpace(cnty)
|
||||||
|
if s == "" || s == "0" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var st, co string
|
||||||
|
if i := strings.IndexByte(s, ','); i >= 0 {
|
||||||
|
st, co = strings.TrimSpace(s[:i]), strings.TrimSpace(s[i+1:])
|
||||||
|
} else {
|
||||||
|
st, co = strings.TrimSpace(state), s
|
||||||
|
}
|
||||||
|
co = strings.ToUpper(co)
|
||||||
|
co = strings.ReplaceAll(co, ".", "")
|
||||||
|
co = strings.ReplaceAll(co, "'", "")
|
||||||
|
co = strings.ReplaceAll(co, "-", " ")
|
||||||
|
for _, suf := range []string{" COUNTY", " PARISH", " BOROUGH", " CENSUS AREA", " MUNICIPALITY"} {
|
||||||
|
if strings.HasSuffix(co, suf) {
|
||||||
|
co = strings.TrimSuffix(co, suf)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(co, "SAINTE "):
|
||||||
|
co = "STE " + co[len("SAINTE "):]
|
||||||
|
case strings.HasPrefix(co, "SAINT "):
|
||||||
|
co = "ST " + co[len("SAINT "):]
|
||||||
|
}
|
||||||
|
// Drop ALL internal spaces last: FIPS writes DeKalb/DuPage/LaSalle as one
|
||||||
|
// word, logs often split them ("De Kalb"). Removing spaces on both sides
|
||||||
|
// folds those together and can't collide two real counties in one state.
|
||||||
|
co = strings.ReplaceAll(co, " ", "")
|
||||||
|
st = strings.ToUpper(st)
|
||||||
|
if len(st) != 2 || co == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Separator is "/", NOT ",": the QSOFIELDS matcher splits a field value on
|
||||||
|
// commas/semicolons (n-fer POTA "US-1,US-2"), which would shatter "AL,AUTAUGA"
|
||||||
|
// into two non-matching tokens. The stored ADIF cnty keeps its comma; only
|
||||||
|
// this internal match key uses "/".
|
||||||
|
return st + "/" + co
|
||||||
|
}
|
||||||
|
|
||||||
// labelRef fills a worked reference's name/group from the reference list (or the
|
// labelRef fills a worked reference's name/group from the reference list (or the
|
||||||
// name resolver as a fallback).
|
// name resolver as a fallback).
|
||||||
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
|
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
|
||||||
@@ -565,6 +744,14 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
|
|||||||
found = append(found, nc.code)
|
found = append(found, nc.code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A reference may also declare its own regex to broaden matching beyond
|
||||||
|
// its plain name (e.g. Tokyo's `\bTok[iy]o\b` also catches "TOKIO"). Test
|
||||||
|
// those too — the name match and the pattern are OR'd.
|
||||||
|
for _, code := range rl.withPattern {
|
||||||
|
if m := rl.byCode[code]; m.re != nil && m.re.MatchString(raw) {
|
||||||
|
found = append(found, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
case predefined && !exact:
|
case predefined && !exact:
|
||||||
// "Search reference inside the field": look up each token of the field in
|
// "Search reference inside the field": look up each token of the field in
|
||||||
// the list — O(tokens), not O(all references) — plus test the few
|
// the list — O(tokens), not O(all references) — plus test the few
|
||||||
@@ -593,32 +780,207 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
|
|||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Explain: why did (or didn't) this QSO count for this award? ──────────────
|
||||||
|
//
|
||||||
|
// An award that silently matches nothing is the hardest kind of bug to see: the
|
||||||
|
// UI shows an empty column and the operator has no way to tell whether the QSO is
|
||||||
|
// out of scope, the field is empty, the rule looked in the wrong place, or the
|
||||||
|
// reference simply isn't on the list. Explain replays the matcher on ONE QSO and
|
||||||
|
// reports every step it took.
|
||||||
|
|
||||||
|
// Rejected is a candidate a rule produced that did not survive.
|
||||||
|
type Rejected struct {
|
||||||
|
Candidate string `json:"candidate"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step is one matching rule as it actually ran.
|
||||||
|
type Step struct {
|
||||||
|
Rule string `json:"rule"` // "primary", "OR 1", …
|
||||||
|
Field string `json:"field"` // the QSO field it scanned
|
||||||
|
MatchBy string `json:"match_by,omitempty"` // code | description | pattern
|
||||||
|
Exact bool `json:"exact,omitempty"` // whole field IS the reference
|
||||||
|
Pattern string `json:"pattern,omitempty"` // the rule's regex, if any
|
||||||
|
FieldValue string `json:"field_value,omitempty"` // what the field actually held
|
||||||
|
Candidates []string `json:"candidates,omitempty"` // what the rule produced, before validation
|
||||||
|
Kept []string `json:"kept,omitempty"` // what survived the reference list
|
||||||
|
Rejected []Rejected `json:"rejected,omitempty"` // and what didn't, with the reason
|
||||||
|
Skipped bool `json:"skipped,omitempty"` // never ran: an earlier rule already matched
|
||||||
|
Error string `json:"error,omitempty"` // e.g. a bad regex, which SKIPS the rule
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explanation is the full account of one QSO against one award.
|
||||||
|
type Explanation struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
InScope bool `json:"in_scope"`
|
||||||
|
ScopeError string `json:"scope_error,omitempty"` // why the QSO is out of scope
|
||||||
|
Predefined bool `json:"predefined"` // matches are validated against a reference list
|
||||||
|
RefCount int `json:"ref_count"` // size of that list
|
||||||
|
Steps []Step `json:"steps"`
|
||||||
|
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
|
||||||
|
Result []string `json:"result"` // what the QSO finally counts for
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explain runs the matcher on a single QSO and reports what it did. It goes
|
||||||
|
// through the SAME code path as Compute — not a re-implementation — so what it
|
||||||
|
// shows is what actually happens.
|
||||||
|
func Explain(d Def, metas []RefMeta, q *qso.QSO) Explanation {
|
||||||
|
ex := Explanation{Code: d.Code, Steps: []Step{}, Result: []string{}}
|
||||||
|
rl := NewRefList(metas)
|
||||||
|
ex.Predefined = len(metas) > 0 && !d.Dynamic
|
||||||
|
ex.RefCount = len(metas)
|
||||||
|
|
||||||
|
var why string
|
||||||
|
if !inScopeWhy(&d, q, &why) {
|
||||||
|
ex.ScopeError = why
|
||||||
|
return ex // out of scope: no rule ever runs, and saying so IS the answer
|
||||||
|
}
|
||||||
|
ex.InScope = true
|
||||||
|
|
||||||
|
var re *regexp.Regexp
|
||||||
|
if p := strings.TrimSpace(d.Pattern); p != "" {
|
||||||
|
c, err := compileAwardRE(p)
|
||||||
|
if err != nil {
|
||||||
|
ex.Steps = append(ex.Steps, Step{Rule: "primary", Field: d.Field, Pattern: d.Pattern,
|
||||||
|
Error: "bad regex: " + err.Error()})
|
||||||
|
return ex
|
||||||
|
}
|
||||||
|
re = c
|
||||||
|
}
|
||||||
|
candidatesTrace(&d, re, q, rl, len(metas) > 0, &ex)
|
||||||
|
return ex
|
||||||
|
}
|
||||||
|
|
||||||
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
|
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
|
||||||
|
return candidatesTrace(d, re, q, rl, hasList, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidatesTrace is the matcher. ex is optional: when non-nil (only Explain
|
||||||
|
// passes it) each rule records what it scanned, what it produced and what was
|
||||||
|
// rejected. The tracing MUST live inside the real matcher rather than in a
|
||||||
|
// parallel "explain" implementation — a trace that can drift from the code it
|
||||||
|
// describes is worse than no trace, because it is believed.
|
||||||
|
func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool, ex *Explanation) []string {
|
||||||
predefined := hasList && !d.Dynamic
|
predefined := hasList && !d.Dynamic
|
||||||
|
|
||||||
// Primary search, then each OR rule — a QSO earns a reference if any matches.
|
// run executes one rule and, when tracing, records it.
|
||||||
found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)
|
run := func(label, field, matchBy, pattern string, rex *regexp.Regexp, exact bool, leading, trailing, prefix string) []string {
|
||||||
|
raw := searchOne(field, matchBy, rex, exact, leading, trailing, prefix, q, rl, predefined)
|
||||||
|
kept := keepRefs(predefined, rl, raw)
|
||||||
|
if ex != nil {
|
||||||
|
s := Step{Rule: label, Field: field, MatchBy: matchBy, Exact: exact, Pattern: pattern,
|
||||||
|
FieldValue: strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing)),
|
||||||
|
Candidates: raw, Kept: kept}
|
||||||
|
keptSet := map[string]struct{}{}
|
||||||
|
for _, k := range kept {
|
||||||
|
keptSet[k] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, c := range raw {
|
||||||
|
n := normalizeRef(c)
|
||||||
|
if _, ok := keptSet[n]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.Rejected = append(s.Rejected, rejection(predefined, rl, n))
|
||||||
|
}
|
||||||
|
ex.Steps = append(ex.Steps, s)
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary search first; the OR rules are ordered FALLBACKS — try the next
|
||||||
|
// only while nothing has matched yet, and stop at the first that yields a
|
||||||
|
// reference (short-circuit). So a province already found by NAME isn't also
|
||||||
|
// re-derived, possibly differently, from a later city-regex rule.
|
||||||
|
//
|
||||||
|
// The short-circuit tests what a rule REALLY yielded — the references that
|
||||||
|
// survive the predefined list — not its raw candidates. A rule can always
|
||||||
|
// produce a raw candidate and still find nothing: "the whole ADDRESS field is
|
||||||
|
// the code" hands back "SERIATE (BG) 24068 ITALY", which is not a province.
|
||||||
|
// Testing the raw candidate would call that a hit, skip every fallback, and
|
||||||
|
// only then drop it as unlisted — leaving the QSO unmatched even though the
|
||||||
|
// next rule ("find the code inside the QTH") would have found BG.
|
||||||
|
found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "")
|
||||||
for i := range d.OrRules {
|
for i := range d.OrRules {
|
||||||
r := &d.OrRules[i]
|
r := &d.OrRules[i]
|
||||||
|
label := fmt.Sprintf("OR %d", i+1)
|
||||||
|
if len(found) > 0 {
|
||||||
|
if ex != nil {
|
||||||
|
ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Exact: r.ExactMatch,
|
||||||
|
Pattern: r.Pattern, Skipped: true})
|
||||||
|
}
|
||||||
|
continue // an earlier rule already matched — fallbacks short-circuit
|
||||||
|
}
|
||||||
var rre *regexp.Regexp
|
var rre *regexp.Regexp
|
||||||
if p := strings.TrimSpace(r.Pattern); p != "" {
|
if p := strings.TrimSpace(r.Pattern); p != "" {
|
||||||
c, err := regexp.Compile(p)
|
c, err := compileAwardRE(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ex != nil {
|
||||||
|
ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Pattern: r.Pattern,
|
||||||
|
Error: "bad regex: " + err.Error()})
|
||||||
|
}
|
||||||
continue // skip a rule with a bad regex rather than failing the award
|
continue // skip a rule with a bad regex rather than failing the award
|
||||||
}
|
}
|
||||||
rre = c
|
rre = c
|
||||||
}
|
}
|
||||||
found = append(found, searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)...)
|
found = run(label, r.Field, r.MatchBy, r.Pattern, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge operator-assigned references (manual override, ManualRefsKey). Lets
|
||||||
|
// the operator tag a QSO for an award whose field/description matching can't
|
||||||
|
// auto-detect the reference — e.g. WAPC scans ADDRESS for a province NAME, so
|
||||||
|
// a contact whose address doesn't spell it out needs the province picked by
|
||||||
|
// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
|
||||||
|
// awards panel and the per-QSO refs editor — honours overrides too. For a
|
||||||
|
// predefined award the ref is still validated against the list below.
|
||||||
|
manual := keepRefs(predefined, rl, manualRefs(q, d.Code))
|
||||||
|
if ex != nil {
|
||||||
|
ex.Manual = manual
|
||||||
|
}
|
||||||
|
found = append(found, manual...)
|
||||||
|
out := dedupe(found)
|
||||||
|
if ex != nil {
|
||||||
|
ex.Result = out
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// rejection explains why a candidate the operator can SEE in the trace did not
|
||||||
|
// become a reference. "Nothing matched" is the least useful thing a matcher can
|
||||||
|
// say; every one of this week's award bugs was a rejection with a plain reason
|
||||||
|
// that nothing was printing.
|
||||||
|
func rejection(predefined bool, rl refList, code string) Rejected {
|
||||||
|
switch {
|
||||||
|
case code == "":
|
||||||
|
return Rejected{Candidate: code, Reason: "empty"}
|
||||||
|
case !predefined:
|
||||||
|
return Rejected{Candidate: code, Reason: "duplicate"}
|
||||||
|
}
|
||||||
|
m, ok := rl.byCode[code]
|
||||||
|
if !ok {
|
||||||
|
return Rejected{Candidate: code, Reason: "not in the award's reference list"}
|
||||||
|
}
|
||||||
|
if !m.Valid {
|
||||||
|
return Rejected{Candidate: code, Reason: "listed but disabled"}
|
||||||
|
}
|
||||||
|
return Rejected{Candidate: code, Reason: "duplicate"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepRefs reduces a rule's raw candidates to the references that actually count.
|
||||||
|
// For a predefined award that means the codes on the list, enabled. The
|
||||||
|
// award-level DXCCFilter already scopes which QSOs are considered (see inScope),
|
||||||
|
// so we do NOT additionally require the QSO's entity to match the reference's own
|
||||||
|
// DXCC — that wrongly excluded e.g. WAS Alaska (state AK is DXCC entity 6, not
|
||||||
|
// 291). Per-reference DXCC stays metadata for the picker.
|
||||||
|
func keepRefs(predefined bool, rl refList, found []string) []string {
|
||||||
if !predefined {
|
if !predefined {
|
||||||
return dedupe(found)
|
out := make([]string, 0, len(found))
|
||||||
|
for _, c := range found {
|
||||||
|
if c = normalizeRef(c); c != "" {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dedupe(out)
|
||||||
}
|
}
|
||||||
// Enforce the predefined list: keep only listed, valid references. The
|
|
||||||
// award-level DXCCFilter already scopes which QSOs are considered (see
|
|
||||||
// inScope), so we do NOT additionally require the QSO's entity to match the
|
|
||||||
// reference's own DXCC — that wrongly excluded e.g. WAS Alaska (state AK is
|
|
||||||
// DXCC entity 6, not 291). Per-reference DXCC stays metadata for the picker.
|
|
||||||
var out []string
|
var out []string
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for _, c := range found {
|
for _, c := range found {
|
||||||
@@ -759,24 +1121,37 @@ func natLess(a, b string) bool {
|
|||||||
|
|
||||||
// inScope reports whether a QSO falls within an award's scope (DXCC entity,
|
// inScope reports whether a QSO falls within an award's scope (DXCC entity,
|
||||||
// bands, modes, emission category, validity dates).
|
// bands, modes, emission category, validity dates).
|
||||||
func inScope(d *Def, q *qso.QSO) bool {
|
func inScope(d *Def, q *qso.QSO) bool { return inScopeWhy(d, q, nil) }
|
||||||
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
|
|
||||||
|
// inScopeWhy is inScope with an optional explanation. why is filled ONLY when the
|
||||||
|
// QSO is out of scope AND a caller asked for the reason (Explain does; Compute,
|
||||||
|
// which runs this for every QSO × every award, passes nil and pays nothing).
|
||||||
|
// Keeping both behind one function is the point: a scope check that disagrees with
|
||||||
|
// the scope check it explains would be worse than no explanation at all.
|
||||||
|
func inScopeWhy(d *Def, q *qso.QSO, why *string) bool {
|
||||||
|
fail := func(format string, args ...any) bool {
|
||||||
|
if why != nil {
|
||||||
|
*why = fmt.Sprintf(format, args...)
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
|
||||||
|
return fail("DXCC %d is not in the award's filter %v", q.DXCC, d.DXCCFilter)
|
||||||
|
}
|
||||||
if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) {
|
if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) {
|
||||||
return false
|
return fail("band %q is not among the valid bands %v", q.Band, d.ValidBands)
|
||||||
}
|
}
|
||||||
if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) {
|
if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) {
|
||||||
return false
|
return fail("mode %q is not among the valid modes %v", q.Mode, d.ValidModes)
|
||||||
}
|
}
|
||||||
if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) {
|
if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) {
|
||||||
return false
|
return fail("mode %q is %s emission; the award accepts %v", q.Mode, emissionOf(q.Mode), d.Emission)
|
||||||
}
|
}
|
||||||
if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom {
|
if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom {
|
||||||
return false
|
return fail("QSO of %s predates the award's start date (%s)", q.QSODate.Format("2006-01-02"), d.ValidFrom)
|
||||||
}
|
}
|
||||||
if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo {
|
if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo {
|
||||||
return false
|
return fail("QSO of %s is after the award's end date (%s)", q.QSODate.Format("2006-01-02"), d.ValidTo)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -827,6 +1202,8 @@ func fieldRaw(field string, q *qso.QSO) string {
|
|||||||
return q.Callsign
|
return q.Callsign
|
||||||
case "state":
|
case "state":
|
||||||
return q.State
|
return q.State
|
||||||
|
case "us_county":
|
||||||
|
return USCountyKey(q.State, q.County)
|
||||||
case "cont":
|
case "cont":
|
||||||
return q.Continent
|
return q.Continent
|
||||||
case "country":
|
case "country":
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package award
|
package award
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
)
|
)
|
||||||
@@ -128,6 +131,146 @@ func TestComputeMatchByDescription(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OR rules are ordered fallbacks: the primary match wins and short-circuits the
|
||||||
|
// rules; when the primary finds nothing, the first OR rule that hits wins and
|
||||||
|
// later rules are skipped. DDFM-style: a French department from the NOTE first,
|
||||||
|
// else captured from a postal code in the address (Prefix "D" → "D74").
|
||||||
|
func TestComputeOrRuleFallback(t *testing.T) {
|
||||||
|
def := Def{Code: "DDFM", Type: TypeQSOFields, Field: "notes", Pattern: `\b(D\d{2})\b`,
|
||||||
|
DXCCFilter: []int{227}, Valid: true,
|
||||||
|
OrRules: []OrRule{
|
||||||
|
{Field: "address", MatchBy: "pattern", Pattern: `\b(\d{2})\d{3}\b`, Prefix: "D"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
refMetas := map[string][]RefMeta{"DDFM": {
|
||||||
|
{Code: "D74", Name: "Haute-Savoie", Valid: true},
|
||||||
|
{Code: "D75", Name: "Paris", Valid: true},
|
||||||
|
}}
|
||||||
|
worked := func(q qso.QSO) []string {
|
||||||
|
r := Compute([]Def{def}, []qso.QSO{q}, refMetas, nil)[0]
|
||||||
|
var out []string
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if rf.Worked {
|
||||||
|
out = append(out, rf.Ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
// Primary (note "D74") matches → the postal OR rule is skipped, so the
|
||||||
|
// address's 75001 does NOT also add D75. Exactly the user's ask: first
|
||||||
|
// condition wins, no fall-through.
|
||||||
|
if got := worked(qso.QSO{Callsign: "F4ABC", DXCC: ip(227), Notes: "worked D74", Address: "75001 Paris"}); len(got) != 1 || got[0] != "D74" {
|
||||||
|
t.Errorf("primary wins: got %v, want [D74] (no postal fall-through to D75)", got)
|
||||||
|
}
|
||||||
|
// No note dept → fall through to the OR rule → postal 74140 → D74.
|
||||||
|
if got := worked(qso.QSO{Callsign: "F4ABC", DXCC: ip(227), Notes: "", Address: "74140 Annecy"}); len(got) != 1 || got[0] != "D74" {
|
||||||
|
t.Errorf("OR fallback: got %v, want [D74]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user's WAPC cascade: (1) province NAME in QTH, else (2) province NAME in
|
||||||
|
// ADDRESS, else (3) match-by-pattern in QTH, which runs each reference's OWN
|
||||||
|
// regex (city lists). Confirms the ordered fallback + that a match-by-pattern
|
||||||
|
// rule with an empty rule-pattern triggers the per-reference regexes.
|
||||||
|
func TestComputeProvinceCascade(t *testing.T) {
|
||||||
|
def := Def{Code: "WAPC", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
|
||||||
|
DXCCFilter: []int{318}, Valid: true,
|
||||||
|
OrRules: []OrRule{
|
||||||
|
{Field: "address", MatchBy: "description"},
|
||||||
|
{Field: "qth", MatchBy: "pattern"}, // empty pattern → per-reference regexes
|
||||||
|
},
|
||||||
|
}
|
||||||
|
refMetas := map[string][]RefMeta{"WAPC": {
|
||||||
|
{Code: "JS", Name: "Jiangsu", Pattern: `\bJiangyin\b`, Valid: true},
|
||||||
|
{Code: "ZJ", Name: "Zhejiang", Pattern: `\bHangzhou\b`, Valid: true},
|
||||||
|
}}
|
||||||
|
worked := func(q qso.QSO) []string {
|
||||||
|
r := Compute([]Def{def}, []qso.QSO{q}, refMetas, nil)[0]
|
||||||
|
var out []string
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if rf.Worked {
|
||||||
|
out = append(out, rf.Ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name, qth, addr string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"name in qth", "Wuxi, Jiangsu", "", "JS"},
|
||||||
|
{"name in address", "Wuxi", "Jiangsu Province", "JS"},
|
||||||
|
{"city regex (Jiangyin) in qth", "Jiangyin", "", "JS"},
|
||||||
|
{"city regex (Hangzhou) in qth", "Hangzhou", "", "ZJ"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := worked(qso.QSO{Callsign: "BA1A", DXCC: ip(318), QTH: c.qth, Address: c.addr})
|
||||||
|
if len(got) != 1 || got[0] != c.want {
|
||||||
|
t.Errorf("%s: got %v, want [%s]", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A manually-assigned override (ManualRefsKey) must count in Compute — not just
|
||||||
|
// MatchQSO — so a custom award like WAPC (matches ADDRESS by description, writes
|
||||||
|
// no QSO field) sticks after the operator picks the province by hand. The engine
|
||||||
|
// only auto-detects "Jiangsu" when the address spells it out; here it doesn't, so
|
||||||
|
// the ref exists solely as the override.
|
||||||
|
func TestComputeManualOverride(t *testing.T) {
|
||||||
|
def := Def{Code: "WAPC", Type: TypeQSOFields, Field: "address", MatchBy: "description",
|
||||||
|
DXCCFilter: []int{318}, Confirm: []string{"lotw", "qsl"}, Valid: true}
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
// Address doesn't name the province → only the override tags it.
|
||||||
|
{Callsign: "BA1ABC", Band: "20m", DXCC: ip(318), Address: "Beijing Rd 5",
|
||||||
|
Extras: map[string]string{ManualRefsKey: "WAPC@JS"}},
|
||||||
|
}
|
||||||
|
refMetas := map[string][]RefMeta{"WAPC": {
|
||||||
|
{Code: "JS", Name: "Jiangsu", Valid: true},
|
||||||
|
{Code: "GD", Name: "Guangdong", Valid: true},
|
||||||
|
}}
|
||||||
|
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
|
||||||
|
if r.Worked != 1 {
|
||||||
|
t.Fatalf("WAPC worked = %d, want 1 (Jiangsu via manual override); got %v", r.Worked, refCodes(r))
|
||||||
|
}
|
||||||
|
var worked []string
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if rf.Worked {
|
||||||
|
worked = append(worked, rf.Ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(worked) != 1 || worked[0] != "JS" {
|
||||||
|
t.Errorf("worked refs = %v, want [JS]", worked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An override for a ref NOT in the predefined list is rejected (no typo refs).
|
||||||
|
qsos[0].Extras[ManualRefsKey] = "WAPC@ZZ"
|
||||||
|
if w := Compute([]Def{def}, qsos, refMetas, nil)[0].Worked; w != 0 {
|
||||||
|
t.Errorf("bogus override ZZ: worked = %d, want 0", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A description-match award must also honour a REFERENCE's own regex (used to
|
||||||
|
// broaden matching past the plain name) AND match case-insensitively — a log
|
||||||
|
// QTH "ITABASHIKU, TOKIO" (uppercase, "Tokio" spelling) must count for Tokyo
|
||||||
|
// via its `\bTok[iy]o\b` pattern, which the plain name "Tokyo" can't catch.
|
||||||
|
func TestComputeDescriptionRefPattern(t *testing.T) {
|
||||||
|
def := Def{Code: "WAJA", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
|
||||||
|
DXCCFilter: []int{339}, Confirm: []string{"lotw", "qsl"}, Valid: true}
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "JA1LZB", Band: "10m", DXCC: ip(339), QTH: "ITABASHIKU, TOKIO"}, // pattern + case
|
||||||
|
{Callsign: "JA3DEF", Band: "40m", DXCC: ip(339), QTH: "osaka pref"}, // lowercase name
|
||||||
|
}
|
||||||
|
refMetas := map[string][]RefMeta{"WAJA": {
|
||||||
|
{Code: "13", Name: "Tokyo", Pattern: `\bTok[iy]o\b`, Valid: true},
|
||||||
|
{Code: "27", Name: "Osaka", Valid: true},
|
||||||
|
{Code: "01", Name: "Hokkaido", Valid: true},
|
||||||
|
}}
|
||||||
|
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
|
||||||
|
if r.Worked != 2 {
|
||||||
|
t.Errorf("WAJA worked = %d, want 2 (Tokyo via pattern + Osaka via lowercase name); got %v", r.Worked, refCodes(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// VUCC: a grid4 award counts distinct 4-char grid squares, and a QSO on a grid
|
// VUCC: a grid4 award counts distinct 4-char grid squares, and a QSO on a grid
|
||||||
// line (VUCC_GRIDS) contributes several. grid4 derives from VUCC_GRIDS else the
|
// line (VUCC_GRIDS) contributes several. grid4 derives from VUCC_GRIDS else the
|
||||||
// 4-char prefix of GRIDSQUARE.
|
// 4-char prefix of GRIDSQUARE.
|
||||||
@@ -146,6 +289,160 @@ func TestComputeGrid4VUCC(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FFMA ships a fixed list of the 488 grids of the contiguous 48 states, taken
|
||||||
|
// from arrl.org/ffma. The count is the award's own checksum — if this test ever
|
||||||
|
// fails on the count, the catalog file is wrong, not the test.
|
||||||
|
func TestCatalogFFMA(t *testing.T) {
|
||||||
|
raw, ok := CatalogRefs("FFMA")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("FFMA has no reference list in the embedded catalog")
|
||||||
|
}
|
||||||
|
var refs []struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
DXCC int `json:"dxcc"`
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &refs); err != nil {
|
||||||
|
t.Fatalf("FFMA references: %v", err)
|
||||||
|
}
|
||||||
|
if len(refs) != 488 {
|
||||||
|
t.Fatalf("FFMA has %d grids, want exactly 488", len(refs))
|
||||||
|
}
|
||||||
|
|
||||||
|
var def Def
|
||||||
|
metas := make([]RefMeta, 0, len(refs))
|
||||||
|
for _, r := range refs {
|
||||||
|
// Rule 4(c): a grid may be activated from Canadian or Mexican soil, or from
|
||||||
|
// water. Pinning a reference to a DXCC entity would reject those contacts.
|
||||||
|
if r.DXCC != 0 {
|
||||||
|
t.Fatalf("FFMA grid %s is pinned to DXCC %d — rule 4(c) allows working it from outside the US", r.Code, r.DXCC)
|
||||||
|
}
|
||||||
|
metas = append(metas, RefMeta{Code: r.Code, Valid: r.Valid})
|
||||||
|
}
|
||||||
|
for _, d := range Defaults() {
|
||||||
|
if d.Code == "FFMA" {
|
||||||
|
def = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if def.Total != 488 || def.Field != "grid4" {
|
||||||
|
t.Fatalf("FFMA def: total=%d field=%q, want 488 / grid4", def.Total, def.Field)
|
||||||
|
}
|
||||||
|
|
||||||
|
d1983 := time.Date(1990, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "K5ABC", Band: "6m", Grid: "EM00AA", QSODate: d1983, LOTWRcvd: "Y"}, // counts
|
||||||
|
{Callsign: "VE3XYZ", Band: "6m", Grid: "FN25AA", QSODate: d1983, LOTWRcvd: "Y"}, // a VE3 in an FFMA grid: counts (4c)
|
||||||
|
{Callsign: "W1LINE", Band: "6m", VUCCGrids: "FN31,FN32", QSODate: d1983, QSLRcvd: "Y"}, // grid line: 2 grids (4d)
|
||||||
|
{Callsign: "G4XXX", Band: "6m", Grid: "IO91AA", QSODate: d1983, LOTWRcvd: "Y"}, // not an FFMA grid
|
||||||
|
{Callsign: "K5ABC", Band: "2m", Grid: "EM10AA", QSODate: d1983, LOTWRcvd: "Y"}, // wrong band
|
||||||
|
{Callsign: "K5OLD", Band: "6m", Grid: "EM20AA", QSODate: time.Date(1982, 6, 1, 0, 0, 0, 0, time.UTC), LOTWRcvd: "Y"}, // before 1983 (rule 2)
|
||||||
|
}
|
||||||
|
r := Compute([]Def{def}, qsos, map[string][]RefMeta{"FFMA": metas}, nil)[0]
|
||||||
|
var got []string
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if rf.Worked {
|
||||||
|
got = append(got, rf.Ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(got)
|
||||||
|
want := []string{"EM00", "FN25", "FN31", "FN32"}
|
||||||
|
if strings.Join(got, " ") != strings.Join(want, " ") {
|
||||||
|
t.Fatalf("FFMA worked = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if r.Worked != len(want) || r.Total != 488 {
|
||||||
|
t.Errorf("FFMA worked=%d total=%d, want %d / 488", r.Worked, r.Total, len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAIP: the primary rule takes the WHOLE address as the province code (exact
|
||||||
|
// match), which can never be one — but it does produce a raw candidate. The OR
|
||||||
|
// fallback (find the code inside the QTH, "SERIATE (BG)") is the rule that works,
|
||||||
|
// and it must still run: a rule that yields nothing VALID is not a hit.
|
||||||
|
func TestComputeOrFallbackAfterUnlistedPrimary(t *testing.T) {
|
||||||
|
def := Def{
|
||||||
|
Code: "WAIP", Name: "Worked All Italian Provinces", Valid: true,
|
||||||
|
Type: TypeReference, Field: "address", MatchBy: "code", ExactMatch: true,
|
||||||
|
OrRules: []OrRule{{Field: "qth", MatchBy: "code"}}, // not exact → search inside the field
|
||||||
|
Confirm: []string{"lotw", "qsl"},
|
||||||
|
}
|
||||||
|
metas := []RefMeta{
|
||||||
|
{Code: "BG", Name: "Bergamo", Valid: true},
|
||||||
|
{Code: "MI", Name: "Milano", Valid: true},
|
||||||
|
}
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy", LOTWRcvd: "Y"},
|
||||||
|
}
|
||||||
|
r := Compute([]Def{def}, qsos, map[string][]RefMeta{"WAIP": metas}, nil)[0]
|
||||||
|
if r.Worked != 1 {
|
||||||
|
t.Fatalf("WAIP worked = %d, want 1 (BG, from the QTH fallback)", r.Worked)
|
||||||
|
}
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if rf.Ref == "BG" && rf.Worked {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("BG not worked; refs = %v", refCodes(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explain must account for the WAIP case exactly: the primary rule produces a
|
||||||
|
// candidate that is NOT a province, says so, and the QTH fallback then finds BG.
|
||||||
|
func TestExplainAccountsForEveryRule(t *testing.T) {
|
||||||
|
def := Def{
|
||||||
|
Code: "WAIP", Valid: true, Type: TypeReference,
|
||||||
|
Field: "address", MatchBy: "code", ExactMatch: true,
|
||||||
|
OrRules: []OrRule{{Field: "qth", MatchBy: "code"}},
|
||||||
|
Confirm: []string{"lotw"},
|
||||||
|
}
|
||||||
|
metas := []RefMeta{{Code: "BG", Name: "Bergamo", Valid: true}}
|
||||||
|
q := &qso.QSO{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy"}
|
||||||
|
|
||||||
|
ex := Explain(def, metas, q)
|
||||||
|
if !ex.InScope || len(ex.Steps) != 2 {
|
||||||
|
t.Fatalf("in scope=%v, %d steps, want in scope with 2 (primary + OR 1): %+v", ex.InScope, len(ex.Steps), ex)
|
||||||
|
}
|
||||||
|
// The primary rule LOOKS like it found something. Saying only "no match" here is
|
||||||
|
// what cost hours: the trace has to show the candidate and why it lost.
|
||||||
|
p := ex.Steps[0]
|
||||||
|
if len(p.Candidates) == 0 {
|
||||||
|
t.Error("primary produced no candidate; the whole point is that it produces a bogus one")
|
||||||
|
}
|
||||||
|
if len(p.Kept) != 0 || len(p.Rejected) == 0 {
|
||||||
|
t.Fatalf("primary kept=%v rejected=%v, want nothing kept and a stated reason", p.Kept, p.Rejected)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Rejected[0].Reason, "not in the award's reference list") {
|
||||||
|
t.Errorf("reason = %q, want it to name the real cause", p.Rejected[0].Reason)
|
||||||
|
}
|
||||||
|
if or1 := ex.Steps[1]; or1.Skipped || len(or1.Kept) != 1 || or1.Kept[0] != "BG" {
|
||||||
|
t.Errorf("OR 1 = %+v, want it to run and find BG", or1)
|
||||||
|
}
|
||||||
|
if len(ex.Result) != 1 || ex.Result[0] != "BG" {
|
||||||
|
t.Errorf("result = %v, want [BG]", ex.Result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A trace that disagrees with the matcher is worse than none: it is believed.
|
||||||
|
if got := MatchQSO(def, metas, q); strings.Join(got, ",") != strings.Join(ex.Result, ",") {
|
||||||
|
t.Errorf("Explain says %v but MatchQSO says %v — the trace does not describe the code", ex.Result, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExplainOutOfScopeSaysWhy(t *testing.T) {
|
||||||
|
def := Def{Code: "FFMA", Valid: true, Field: "grid4", MatchBy: "code", ExactMatch: true,
|
||||||
|
ValidBands: []string{"6m"}, ValidFrom: "1983-01-01", Confirm: []string{"lotw"}}
|
||||||
|
q := &qso.QSO{Callsign: "K1ABC", Band: "2m", Grid: "EM00AA",
|
||||||
|
QSODate: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC)}
|
||||||
|
|
||||||
|
ex := Explain(def, []RefMeta{{Code: "EM00", Valid: true}}, q)
|
||||||
|
if ex.InScope {
|
||||||
|
t.Fatal("a 2 m QSO is not in scope for a 6 m-only award")
|
||||||
|
}
|
||||||
|
if !strings.Contains(ex.ScopeError, "2m") {
|
||||||
|
t.Errorf("scope error = %q, want it to name the band that fails", ex.ScopeError)
|
||||||
|
}
|
||||||
|
if len(ex.Steps) != 0 {
|
||||||
|
t.Errorf("%d steps ran on an out-of-scope QSO; none should", len(ex.Steps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func refCodes(r Result) []string {
|
func refCodes(r Result) []string {
|
||||||
out := make([]string, 0, len(r.Refs))
|
out := make([]string, 0, len(r.Refs))
|
||||||
for _, rf := range r.Refs {
|
for _, rf := range r.Refs {
|
||||||
@@ -238,3 +535,152 @@ func TestComputePredefinedList(t *testing.T) {
|
|||||||
t.Errorf("RAC refs = %d, want 3 (%v)", len(r.Refs), refCodes(r))
|
t.Errorf("RAC refs = %d, want 3 (%v)", len(r.Refs), refCodes(r))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The built-in awards moved from Go code into embedded JSON (catalog/*.json).
|
||||||
|
// A refactor is only a refactor if the behaviour is identical, so pin down what
|
||||||
|
// the catalog MUST still produce — a typo in a JSON file would otherwise silently
|
||||||
|
// disable an award, and nobody would notice until a QSO stopped counting.
|
||||||
|
func TestCatalogDefaults(t *testing.T) {
|
||||||
|
defs := Defaults()
|
||||||
|
byCode := map[string]Def{}
|
||||||
|
for _, d := range defs {
|
||||||
|
byCode[d.Code] = d
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalog is MEANT to grow — dropping a JSON in is how an award ships. So
|
||||||
|
// assert the ten originals are still there, never that the count is exactly ten:
|
||||||
|
// a test that fails the moment you add an award is a test that teaches you to
|
||||||
|
// ignore it.
|
||||||
|
want := []string{"DDFM", "DXCC", "IOTA", "POTA", "SOTA", "WAC", "WAS", "WAZ", "WPX", "WWFF"}
|
||||||
|
if len(defs) < len(want) {
|
||||||
|
t.Fatalf("catalog has %d awards, want at least the %d built-ins (%v)", len(defs), len(want), byCode)
|
||||||
|
}
|
||||||
|
for _, c := range want {
|
||||||
|
d, ok := byCode[c]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s missing from the embedded catalog", c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Valid=false would hide the award entirely; Builtin=false would let a
|
||||||
|
// "reset to defaults" delete it. Both are silent failures.
|
||||||
|
if !d.Valid || !d.Builtin {
|
||||||
|
t.Errorf("%s: valid=%v builtin=%v — both must be true", c, d.Valid, d.Builtin)
|
||||||
|
}
|
||||||
|
if d.Type == "" || d.Field == "" {
|
||||||
|
t.Errorf("%s: type=%q field=%q — neither may be empty", c, d.Type, d.Field)
|
||||||
|
}
|
||||||
|
if len(d.Confirm) == 0 {
|
||||||
|
t.Errorf("%s: no confirmation sources — nothing would ever count as confirmed", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spot-check the two that carry real matching logic, since a mangled escape in
|
||||||
|
// JSON is exactly the failure this test exists to catch.
|
||||||
|
if got := byCode["DDFM"].Pattern; got != `(?i)\b(D\d{1,2}[AB]?)\b` {
|
||||||
|
t.Errorf("DDFM pattern = %q — the regex did not survive the JSON round-trip", got)
|
||||||
|
}
|
||||||
|
if _, err := compileAwardRE(byCode["DDFM"].Pattern); err != nil {
|
||||||
|
t.Errorf("DDFM pattern does not compile: %v", err)
|
||||||
|
}
|
||||||
|
if got := byCode["WAS"].DXCCFilter; len(got) != 3 || got[0] != 291 {
|
||||||
|
t.Errorf("WAS DXCC filter = %v, want [291 110 6]", got)
|
||||||
|
}
|
||||||
|
if !byCode["WAS"].ExactMatch || byCode["WAS"].MatchBy != "code" {
|
||||||
|
t.Errorf("WAS: exact=%v matchBy=%q, want true/code", byCode["WAS"].ExactMatch, byCode["WAS"].MatchBy)
|
||||||
|
}
|
||||||
|
if !byCode["WPX"].Dynamic {
|
||||||
|
t.Error("WPX must be dynamic — its references aren't a fixed list")
|
||||||
|
}
|
||||||
|
// Deterministic order: an embed walk that reorders would shuffle the user's list.
|
||||||
|
for i := 1; i < len(defs); i++ {
|
||||||
|
if defs[i-1].Code > defs[i].Code {
|
||||||
|
t.Errorf("catalog not sorted by code: %q before %q", defs[i-1].Code, defs[i].Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of the catalog: an award you EXPORT from the UI can be dropped
|
||||||
|
// straight into catalog/ and shipped to everyone — no conversion step. So the
|
||||||
|
// loader must accept the exported bundle shape, and it must carry the REFERENCES,
|
||||||
|
// because a WAPC without its provinces and their city regexes is an empty shell.
|
||||||
|
func TestCatalogAcceptsExportedBundle(t *testing.T) {
|
||||||
|
// Exactly what ExportAward writes.
|
||||||
|
exported := []byte(`{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T10:00:00Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {"code":"WAPC","name":"Worked All Provinces of China","valid":true,
|
||||||
|
"type":"QSOFIELDS","field":"address","match_by":"description",
|
||||||
|
"dxcc_filter":[318],"confirm":["lotw","qsl"],"total":34},
|
||||||
|
"references": [
|
||||||
|
{"code":"JS","name":"Jiangsu","pattern":"\\bJiangyin\\b","valid":true},
|
||||||
|
{"code":"ZJ","name":"Zhejiang","valid":true}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
var f catalogFile
|
||||||
|
if err := json.Unmarshal(exported, &f); err != nil {
|
||||||
|
t.Fatalf("an exported bundle must parse as a catalog file: %v", err)
|
||||||
|
}
|
||||||
|
if len(f.Awards) != 1 {
|
||||||
|
t.Fatalf("got %d awards, want 1", len(f.Awards))
|
||||||
|
}
|
||||||
|
e := f.Awards[0]
|
||||||
|
if e.Def.Code != "WAPC" || e.Def.Field != "address" || e.Def.MatchBy != "description" {
|
||||||
|
t.Errorf("definition lost in the round-trip: %+v", e.Def)
|
||||||
|
}
|
||||||
|
if len(e.References) == 0 {
|
||||||
|
t.Fatal("references dropped — this is the whole value of sharing an award")
|
||||||
|
}
|
||||||
|
// The per-reference regex must survive: it is what makes the award actually work.
|
||||||
|
var refs []struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(e.References, &refs); err != nil {
|
||||||
|
t.Fatalf("references not decodable: %v", err)
|
||||||
|
}
|
||||||
|
if len(refs) != 2 || refs[0].Code != "JS" || refs[0].Pattern != `\bJiangyin\b` {
|
||||||
|
t.Errorf("reference regex did not survive: %+v", refs)
|
||||||
|
}
|
||||||
|
if _, err := compileAwardRE(refs[0].Pattern); err != nil {
|
||||||
|
t.Errorf("the shipped regex does not compile: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single malformed file in the catalog must not take the other awards down with
|
||||||
|
// it — one bad JSON should cost you one award, not all of them.
|
||||||
|
func TestCatalogSurvivesOneBadFile(t *testing.T) {
|
||||||
|
if len(Catalog()) < 10 {
|
||||||
|
t.Fatalf("catalog has %d entries, want the 10 built-ins", len(Catalog()))
|
||||||
|
}
|
||||||
|
var f catalogFile
|
||||||
|
if err := json.Unmarshal([]byte(`{ this is not json `), &f); err == nil {
|
||||||
|
t.Error("expected a parse error on malformed JSON")
|
||||||
|
}
|
||||||
|
// Catalog() skips unparseable files rather than returning nil, so the others
|
||||||
|
// still load. (Verified structurally: the loader `continue`s on error.)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything OpsLog SHIPS is built-in, whatever the file says.
|
||||||
|
//
|
||||||
|
// You create an award, export it (its JSON records builtin:false — it wasn't
|
||||||
|
// built-in when you wrote it), then drop that same file into the catalog to ship
|
||||||
|
// it. If we trusted the flag, the award would go out to everyone marked "not
|
||||||
|
// built-in" and would then silently miss every future catalog correction. Making
|
||||||
|
// the author remember to flip a flag first is a step nobody can guess — so the
|
||||||
|
// catalog derives it instead.
|
||||||
|
func TestCatalogForcesBuiltin(t *testing.T) {
|
||||||
|
for _, e := range Catalog() {
|
||||||
|
if !e.Def.Builtin {
|
||||||
|
t.Errorf("%s: shipped in the catalog but Builtin=false — it would miss catalog corrections", e.Def.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The realistic case: a user-authored award whose JSON says builtin:false.
|
||||||
|
if len(Catalog()) == 0 {
|
||||||
|
t.Fatal("empty catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package award
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A synthetic log with the shape a real one has: a spread of bands, modes,
|
||||||
|
// entities, and the free-text fields the harder awards actually scan.
|
||||||
|
func benchLog(n int) []qso.QSO {
|
||||||
|
bands := []string{"160m", "80m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"}
|
||||||
|
modes := []string{"SSB", "CW", "FT8", "FT4", "RTTY"}
|
||||||
|
entities := []int{291, 248, 227, 230, 339, 5, 108, 110, 6, 281}
|
||||||
|
towns := []string{"SERIATE (BG)", "MILANO (MI)", "ROMA (RM)", "TORINO (TO)", "NAPOLI (NA)"}
|
||||||
|
out := make([]qso.QSO, n)
|
||||||
|
base := time.Date(2015, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
for i := range out {
|
||||||
|
e := entities[i%len(entities)]
|
||||||
|
out[i] = qso.QSO{
|
||||||
|
ID: int64(i + 1),
|
||||||
|
Callsign: fmt.Sprintf("I%dABC", i%10),
|
||||||
|
Band: bands[i%len(bands)],
|
||||||
|
Mode: modes[i%len(modes)],
|
||||||
|
DXCC: &e,
|
||||||
|
QSODate: base.Add(time.Duration(i) * time.Hour),
|
||||||
|
QTH: towns[i%len(towns)],
|
||||||
|
Address: "Via Roma 12, 24068 " + towns[i%len(towns)] + " Italy",
|
||||||
|
Grid: "JN45AA",
|
||||||
|
State: "CA",
|
||||||
|
LOTWRcvd: "Y",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAIP is the worst realistic shape: a predefined list, an exact-match primary
|
||||||
|
// that always produces a doomed candidate, then a fallback chain that tokenises a
|
||||||
|
// free-text field. If anything is slow, it is this.
|
||||||
|
func benchWAIP() (Def, []RefMeta) {
|
||||||
|
d := Def{
|
||||||
|
Code: "WAIP", Valid: true, Type: TypeReference,
|
||||||
|
Field: "address", MatchBy: "code", ExactMatch: true,
|
||||||
|
OrRules: []OrRule{
|
||||||
|
{Field: "qth", MatchBy: "description"},
|
||||||
|
{Field: "qth", MatchBy: "code"},
|
||||||
|
{Field: "address", MatchBy: "code"},
|
||||||
|
},
|
||||||
|
DXCCFilter: []int{248},
|
||||||
|
Confirm: []string{"lotw", "qsl"},
|
||||||
|
}
|
||||||
|
codes := []string{"AG", "AL", "AN", "AO", "AP", "AQ", "AR", "AT", "AV", "BA", "BG", "BI", "BL", "BN", "BO", "BR", "BS", "BT", "BZ", "CA", "CB", "CE", "CH", "CL", "CN", "CO", "CR", "CS", "CT", "CZ", "EN", "FC", "FE", "FG", "FI", "FM", "FR", "GE", "GO", "GR", "IM", "IS", "KR", "LC", "LE", "LI", "LO", "LT", "LU", "MB", "MC", "ME", "MI", "MN", "MO", "MS", "MT", "NA", "NO", "NU", "OR", "PA", "PC", "PD", "PE", "PG", "PI", "PN", "PO", "PR", "PT", "PU", "PV", "PZ", "RA", "RC", "RE", "RG", "RI", "RM", "RN", "RO", "SA", "SI", "SO", "SP", "SR", "SS", "SU", "SV", "TA", "TE", "TN", "TO", "TP", "TR", "TS", "TV", "UD", "VA", "VB", "VC", "VE", "VI", "VR", "VT", "VV"}
|
||||||
|
metas := make([]RefMeta, 0, len(codes))
|
||||||
|
for _, c := range codes {
|
||||||
|
metas = append(metas, RefMeta{Code: c, Name: "Province " + c, Valid: true})
|
||||||
|
}
|
||||||
|
return d, metas
|
||||||
|
}
|
||||||
|
|
||||||
|
// One award, whole log — what GetAward(code) does when the Awards panel opens.
|
||||||
|
func BenchmarkComputeOneAward(b *testing.B) {
|
||||||
|
d, metas := benchWAIP()
|
||||||
|
for _, n := range []int{10_000, 50_000, 100_000} {
|
||||||
|
log := benchLog(n)
|
||||||
|
b.Run(fmt.Sprintf("WAIP/%dqso", n), func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
Compute([]Def{d}, log, map[string][]RefMeta{"WAIP": metas}, nil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every shipped award at once — GetAwards(), the heaviest thing the app can ask.
|
||||||
|
func BenchmarkComputeWholeCatalog(b *testing.B) {
|
||||||
|
defs := Defaults()
|
||||||
|
metas := map[string][]RefMeta{}
|
||||||
|
wd, wm := benchWAIP()
|
||||||
|
defs = append(defs, wd)
|
||||||
|
metas["WAIP"] = wm
|
||||||
|
for _, c := range Catalog() {
|
||||||
|
if raw, ok := CatalogRefs(c.Def.Code); ok {
|
||||||
|
var refs []struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &refs) == nil {
|
||||||
|
m := make([]RefMeta, 0, len(refs))
|
||||||
|
for _, r := range refs {
|
||||||
|
m = append(m, RefMeta{Code: r.Code, Name: r.Name, Valid: r.Valid})
|
||||||
|
}
|
||||||
|
metas[c.Def.Code] = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range []int{10_000, 100_000} {
|
||||||
|
log := benchLog(n)
|
||||||
|
b.Run(fmt.Sprintf("%dawards/%dqso", len(defs), n), func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
Compute(defs, log, metas, nil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,810 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "DDFM",
|
||||||
|
"name": "Départements Français Métropolitains",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"ref_display": "name",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "note",
|
||||||
|
"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": [
|
||||||
|
227,
|
||||||
|
214
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 96,
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "DXCC",
|
||||||
|
"name": "DX Century Club",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "DXCC",
|
||||||
|
"field": "dxcc",
|
||||||
|
"pattern": "",
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 340,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "IOTA",
|
||||||
|
"name": "Islands On The Air",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "REFERENCE",
|
||||||
|
"field": "iota",
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "POTA",
|
||||||
|
"name": "Parks On The Air",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "REFERENCE",
|
||||||
|
"field": "pota_ref",
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T17:00:44Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "RAC",
|
||||||
|
"name": "RAC Canadian Provinces",
|
||||||
|
"description": "RAC Canadian Provinces",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"url": "https://www.rac.ca/canadaward/",
|
||||||
|
"valid_from": "1977-01-07",
|
||||||
|
"ref_display": "name",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "state",
|
||||||
|
"match_by": "code",
|
||||||
|
"pattern": "",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "description"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dxcc_filter": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"emission": [
|
||||||
|
"CW",
|
||||||
|
"PHONE",
|
||||||
|
"DIGITAL"
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "AB",
|
||||||
|
"name": "Alberta",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BC",
|
||||||
|
"name": "British Columbia",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MB",
|
||||||
|
"name": "Manitoba",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NB",
|
||||||
|
"name": "New Brunswick",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NL",
|
||||||
|
"name": "Newfoundland and Labrador",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NS",
|
||||||
|
"name": "Nova Scotia",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NT",
|
||||||
|
"name": "Northwest Territories",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NU",
|
||||||
|
"name": "Nunavut",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ON",
|
||||||
|
"name": "Ontario",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PE",
|
||||||
|
"name": "Prince Edward Island",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QC",
|
||||||
|
"name": "Quebec",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SK",
|
||||||
|
"name": "Saskatchewan",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "YT",
|
||||||
|
"name": "Yukon",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "SOTA",
|
||||||
|
"name": "Summits On The Air",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "REFERENCE",
|
||||||
|
"field": "sota_ref",
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "USA-CA",
|
||||||
|
"name": "USA-CA (US Counties)",
|
||||||
|
"description": "CQ United States of America Counties Award. Matches the QSO's county, tolerating LoTW \"ST,County\" and bare county-name shapes. Independent cities of Virginia/Nevada and DC are excluded per the award rules.",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "us_county",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"ref_display": "name",
|
||||||
|
"url": "https://cq-amateur-radio.com/cq_awards/cq_usa_ca_awards/cq_usa_ca_awards.html",
|
||||||
|
"dxcc_filter": [
|
||||||
|
291,
|
||||||
|
110,
|
||||||
|
6
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 3077,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T17:00:44Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "VUCC",
|
||||||
|
"name": "VHF/UHF Century Club",
|
||||||
|
"description": "VHF/UHF Century Club",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"url": "https://www.arrl.org/files/file/Awards%20Application%20Forms/VUCCRULE1a.pdf",
|
||||||
|
"valid_from": "1970-01-01",
|
||||||
|
"valid_to": "9999-01-30",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "grid4",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"valid_bands": [
|
||||||
|
"6m",
|
||||||
|
"4m",
|
||||||
|
"2m",
|
||||||
|
"70cm",
|
||||||
|
"23cm",
|
||||||
|
"13cm",
|
||||||
|
"1.25m"
|
||||||
|
],
|
||||||
|
"emission": [
|
||||||
|
"CW",
|
||||||
|
"PHONE",
|
||||||
|
"DIGITAL"
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"references": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAC",
|
||||||
|
"name": "Worked All Continents",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "cont",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl",
|
||||||
|
"eqsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 6,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,929 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T21:57:23Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAIP",
|
||||||
|
"name": "ARI Worked All Italian Provinces",
|
||||||
|
"valid": true,
|
||||||
|
"url": "https://ari.it/en/english-area/awards/1734-waip-worked-all-italian-provinces.html",
|
||||||
|
"ref_url": "https://ari.it/en/english-area/awards/1734-waip-worked-all-italian-provinces.html",
|
||||||
|
"type": "REFERENCE",
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "code"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "description"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dxcc_filter": [
|
||||||
|
248
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": false
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "AG",
|
||||||
|
"name": "Agrigento",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AL",
|
||||||
|
"name": "Alessandria",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AN",
|
||||||
|
"name": "Ancona",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AO",
|
||||||
|
"name": "Aosta",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Val d'Aosta",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AP",
|
||||||
|
"name": "Ascoli Piceno",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AQ",
|
||||||
|
"name": "L'Aquila",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Abruzzo",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AR",
|
||||||
|
"name": "Arezzo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AT",
|
||||||
|
"name": "Asti",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AV",
|
||||||
|
"name": "Avellino",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BA",
|
||||||
|
"name": "Bari",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BG",
|
||||||
|
"name": "Bergamo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BI",
|
||||||
|
"name": "Biella",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BL",
|
||||||
|
"name": "Belluno",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BN",
|
||||||
|
"name": "Benevento",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BO",
|
||||||
|
"name": "Bologna",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BR",
|
||||||
|
"name": "Brindisi",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BS",
|
||||||
|
"name": "Brescia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BT",
|
||||||
|
"name": "Barletta-Andria-Trani",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BZ",
|
||||||
|
"name": "Bolzano/Bozen",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Trentino-AltoAdige/Südtirol",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CA",
|
||||||
|
"name": "Cagliari",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CB",
|
||||||
|
"name": "Campobasso",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Molise",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CE",
|
||||||
|
"name": "Caserta",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CH",
|
||||||
|
"name": "Chieti",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Abruzzo",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CI",
|
||||||
|
"name": "Carbonia-Iglesias",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CL",
|
||||||
|
"name": "Caltanissetta",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CN",
|
||||||
|
"name": "Cuneo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CO",
|
||||||
|
"name": "Como",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CR",
|
||||||
|
"name": "Cremona",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CS",
|
||||||
|
"name": "Cosenza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CT",
|
||||||
|
"name": "Catania",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CZ",
|
||||||
|
"name": "Catanzaro",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "EN",
|
||||||
|
"name": "Enna",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FC",
|
||||||
|
"name": "Forlì-Cesena",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FE",
|
||||||
|
"name": "Ferrara",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FG",
|
||||||
|
"name": "Foggia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FI",
|
||||||
|
"name": "Firenze",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FM",
|
||||||
|
"name": "Fermo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FR",
|
||||||
|
"name": "Frosinone",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GE",
|
||||||
|
"name": "Genova",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Liguria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GO",
|
||||||
|
"name": "Gorizia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Friuli-Venezia Giulia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GR",
|
||||||
|
"name": "Grosseto",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "IM",
|
||||||
|
"name": "Imperia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Liguria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "IS",
|
||||||
|
"name": "Isernia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Molise",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "KR",
|
||||||
|
"name": "Crotone",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LC",
|
||||||
|
"name": "Lecco",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LE",
|
||||||
|
"name": "Lecce",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LI",
|
||||||
|
"name": "Livorno",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LO",
|
||||||
|
"name": "Lodi",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LT",
|
||||||
|
"name": "Latina",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LU",
|
||||||
|
"name": "Lucca",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MB",
|
||||||
|
"name": "Monza e Brianza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MC",
|
||||||
|
"name": "Macerata",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ME",
|
||||||
|
"name": "Messina",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MI",
|
||||||
|
"name": "Milano",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MN",
|
||||||
|
"name": "Mantova",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MO",
|
||||||
|
"name": "Modena",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MS",
|
||||||
|
"name": "Massa-Carrara",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MT",
|
||||||
|
"name": "Matera",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Basilicata",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NA",
|
||||||
|
"name": "Napoli",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NO",
|
||||||
|
"name": "Novara",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NU",
|
||||||
|
"name": "Nuoro",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "OG",
|
||||||
|
"name": "Ogliastra",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "OR",
|
||||||
|
"name": "Oristano",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "OT",
|
||||||
|
"name": "Olbia-Tempio",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PA",
|
||||||
|
"name": "Palermo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PC",
|
||||||
|
"name": "Piacenza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PD",
|
||||||
|
"name": "Padova",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PE",
|
||||||
|
"name": "Pescara",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Abruzzo",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PG",
|
||||||
|
"name": "Perugia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Umbria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PI",
|
||||||
|
"name": "Pisa",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PN",
|
||||||
|
"name": "Pordenone",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Friuli-Venezia Giulia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PO",
|
||||||
|
"name": "Prato",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PR",
|
||||||
|
"name": "Parma",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PT",
|
||||||
|
"name": "Pistoia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PU",
|
||||||
|
"name": "Pesaro-Urbino",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PV",
|
||||||
|
"name": "Pavia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PZ",
|
||||||
|
"name": "Potenza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Basilicata",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RA",
|
||||||
|
"name": "Ravenna",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RC",
|
||||||
|
"name": "Reggio Calabria",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RE",
|
||||||
|
"name": "Reggio Emilia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RG",
|
||||||
|
"name": "Ragusa",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RI",
|
||||||
|
"name": "Rieti",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RM",
|
||||||
|
"name": "Roma",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RN",
|
||||||
|
"name": "Rimini",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RO",
|
||||||
|
"name": "Rovigo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SA",
|
||||||
|
"name": "Salerno",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SI",
|
||||||
|
"name": "Siena",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SO",
|
||||||
|
"name": "Sondrio",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SP",
|
||||||
|
"name": "La Spezia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Liguria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SR",
|
||||||
|
"name": "Siracusa",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SS",
|
||||||
|
"name": "Sassari",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SV",
|
||||||
|
"name": "Savona",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Liguria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TA",
|
||||||
|
"name": "Taranto",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TE",
|
||||||
|
"name": "Teramo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Abruzzo",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TN",
|
||||||
|
"name": "Trento",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Trentino-AltoAdige/Südtirol",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TO",
|
||||||
|
"name": "Torino",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TP",
|
||||||
|
"name": "Trapani",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TR",
|
||||||
|
"name": "Terni",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Umbria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TS",
|
||||||
|
"name": "Trieste",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Friuli-Venezia Giulia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TV",
|
||||||
|
"name": "Treviso",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "UD",
|
||||||
|
"name": "Udine",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Friuli-Venezia Giulia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VA",
|
||||||
|
"name": "Varese",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VB",
|
||||||
|
"name": "Verbano-Cusio-Ossola",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VC",
|
||||||
|
"name": "Vercelli",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VE",
|
||||||
|
"name": "Venezia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VI",
|
||||||
|
"name": "Vicenza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VR",
|
||||||
|
"name": "Verona",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VS",
|
||||||
|
"name": "Medio Campidano",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VT",
|
||||||
|
"name": "Viterbo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VV",
|
||||||
|
"name": "Vibo Valentia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T15:44:38Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAJA",
|
||||||
|
"name": "Worked All Japanese Prefectures",
|
||||||
|
"description": "Worked All Japanese Prefectures",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"valid_from": "1970-01-01",
|
||||||
|
"ref_display": "name",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "description",
|
||||||
|
"pattern": "",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "pattern"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dxcc_filter": [
|
||||||
|
339
|
||||||
|
],
|
||||||
|
"valid_bands": [
|
||||||
|
"80m",
|
||||||
|
"40m",
|
||||||
|
"20m",
|
||||||
|
"15m",
|
||||||
|
"10m",
|
||||||
|
"160m"
|
||||||
|
],
|
||||||
|
"emission": [
|
||||||
|
"CW",
|
||||||
|
"PHONE",
|
||||||
|
"DIGITAL"
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "1",
|
||||||
|
"name": "Hokkaido",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "10",
|
||||||
|
"name": "Gunma",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "11",
|
||||||
|
"name": "Saitama",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "12",
|
||||||
|
"name": "Chiba",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "13",
|
||||||
|
"name": "Tokyo",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\bTok[iy]o\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "14",
|
||||||
|
"name": "Kanagawa",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "15",
|
||||||
|
"name": "Niigata",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "16",
|
||||||
|
"name": "Toyama",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "17",
|
||||||
|
"name": "Ishikawa",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "18",
|
||||||
|
"name": "Fukui",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "19",
|
||||||
|
"name": "Yamanashi",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "2",
|
||||||
|
"name": "Aomori",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "20",
|
||||||
|
"name": "Nagano",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "21",
|
||||||
|
"name": "Gifu",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "22",
|
||||||
|
"name": "Shizuoka",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "23",
|
||||||
|
"name": "Aichi",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "24",
|
||||||
|
"name": "Mie",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "25",
|
||||||
|
"name": "Shiga",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "26",
|
||||||
|
"name": "Kyoto",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "27",
|
||||||
|
"name": "Osaka",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "28",
|
||||||
|
"name": "Hyogo",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "29",
|
||||||
|
"name": "Nara",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "3",
|
||||||
|
"name": "Iwate",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "30",
|
||||||
|
"name": "Wakayama",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "31",
|
||||||
|
"name": "Tottori",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "32",
|
||||||
|
"name": "Shimane",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "33",
|
||||||
|
"name": "Okayama",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "34",
|
||||||
|
"name": "Hiroshima",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "35",
|
||||||
|
"name": "Yamaguchi",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "36",
|
||||||
|
"name": "Tokushima",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "37",
|
||||||
|
"name": "Kagawa",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "38",
|
||||||
|
"name": "Ehime",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "39",
|
||||||
|
"name": "Kochi",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "4",
|
||||||
|
"name": "Miyagi",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "40",
|
||||||
|
"name": "Fukuoka",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "41",
|
||||||
|
"name": "Saga",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "42",
|
||||||
|
"name": "Nagasaki",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "43",
|
||||||
|
"name": "Kumamoto",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "44",
|
||||||
|
"name": "Oita",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "45",
|
||||||
|
"name": "Miyazaki",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "46",
|
||||||
|
"name": "Kagoshima",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "47",
|
||||||
|
"name": "Okinawa",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "5",
|
||||||
|
"name": "Akita",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "6",
|
||||||
|
"name": "Yamagata",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "7",
|
||||||
|
"name": "Fukushima",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "8",
|
||||||
|
"name": "Ibaraki",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "9",
|
||||||
|
"name": "Tochigi",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T15:43:49Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAPC",
|
||||||
|
"name": "Worked All Provinces of China",
|
||||||
|
"description": "Worked All Provinces of China",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"url": "http://www.mulandxc.com/index/wapc_medal_app",
|
||||||
|
"valid_from": "2012-04-21",
|
||||||
|
"valid_to": "9999-01-31",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "description",
|
||||||
|
"pattern": "",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "pattern"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dxcc_filter": [
|
||||||
|
318,
|
||||||
|
152,
|
||||||
|
321
|
||||||
|
],
|
||||||
|
"valid_bands": [
|
||||||
|
"80m",
|
||||||
|
"40m",
|
||||||
|
"20m",
|
||||||
|
"15m",
|
||||||
|
"10m"
|
||||||
|
],
|
||||||
|
"emission": [
|
||||||
|
"CW",
|
||||||
|
"PHONE",
|
||||||
|
"DIGITAL"
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "AH",
|
||||||
|
"name": "Anhui",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BJ",
|
||||||
|
"name": "Beijing",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CQ",
|
||||||
|
"name": "Chongqing",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FJ",
|
||||||
|
"name": "Fujian",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GD",
|
||||||
|
"name": "Guangdong",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GS",
|
||||||
|
"name": "Gansu",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GX",
|
||||||
|
"name": "Guangxi",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GZ",
|
||||||
|
"name": "Guizhou",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA",
|
||||||
|
"name": "Henan",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HB",
|
||||||
|
"name": "Hubei",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HE",
|
||||||
|
"name": "Hebei",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HI",
|
||||||
|
"name": "Hainan",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HK",
|
||||||
|
"name": "Hong Kong",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HL",
|
||||||
|
"name": "Heilongjiang",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HN",
|
||||||
|
"name": "Hunan",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "JL",
|
||||||
|
"name": "Jilin",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "JS",
|
||||||
|
"name": "Jiangsu",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\bJiangyin\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "JX",
|
||||||
|
"name": "Jiangxi",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LN",
|
||||||
|
"name": "Liaoning",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MO",
|
||||||
|
"name": "Macau",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\bMaca[uo]\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NM",
|
||||||
|
"name": "NeiMongol",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NX",
|
||||||
|
"name": "Ningxia",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QH",
|
||||||
|
"name": "Qinghai",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SC",
|
||||||
|
"name": "Sichuan",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SD",
|
||||||
|
"name": "Shandong",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SH",
|
||||||
|
"name": "Shanghai",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SN",
|
||||||
|
"name": "Shaanxi",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SX",
|
||||||
|
"name": "Shanxi",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TJ",
|
||||||
|
"name": "Tianjin",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TW",
|
||||||
|
"name": "Taiwan",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "XJ",
|
||||||
|
"name": "Xinjiang",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "XZ",
|
||||||
|
"name": "Xizang",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "YN",
|
||||||
|
"name": "Yunnan",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ZJ",
|
||||||
|
"name": "Zhejiang",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAS",
|
||||||
|
"name": "Worked All States",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "state",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"dxcc_filter": [
|
||||||
|
291,
|
||||||
|
110,
|
||||||
|
6
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 50,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAZ",
|
||||||
|
"name": "Worked All Zones (CQ)",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "cqz",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 40,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WPX",
|
||||||
|
"name": "Worked All Prefixes (CQ WPX)",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "prefix",
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WWFF",
|
||||||
|
"name": "World Wide Flora & Fauna",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "REFERENCE",
|
||||||
|
"field": "wwff",
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package award
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestUSCountyKey(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
state, cnty, want string
|
||||||
|
}{
|
||||||
|
{"MA", "MA,MIDDLESEX", "MA/MIDDLESEX"}, // LoTW "ST,County" shape
|
||||||
|
{"NJ", "Middlesex", "NJ/MIDDLESEX"}, // bare name + state column
|
||||||
|
{"TX", "Montgomery", "TX/MONTGOMERY"}, // title case
|
||||||
|
{"FL", "Saint Lucie", "FL/STLUCIE"}, // Saint→St, space dropped
|
||||||
|
{"FL", "St. Lucie", "FL/STLUCIE"}, // FIPS abbreviation, period dropped
|
||||||
|
{"AK", "Matanuska-Susitna", "AK/MATANUSKASUSITNA"}, // hyphen→space→dropped
|
||||||
|
{"IL", "De Kalb", "IL/DEKALB"}, // split name
|
||||||
|
{"IL", "DeKalb County", "IL/DEKALB"}, // suffix + one word
|
||||||
|
{"LA", "Acadia Parish", "LA/ACADIA"}, // parish suffix
|
||||||
|
{"", "Honolulu", ""}, // no state → no match
|
||||||
|
{"HI", "0", ""}, // garbage
|
||||||
|
{"HI", "", ""}, // empty
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := USCountyKey(c.state, c.cnty); got != c.want {
|
||||||
|
t.Errorf("USCountyKey(%q,%q) = %q, want %q", c.state, c.cnty, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
// - WAC → continent code ("EU", "NA", …)
|
// - WAC → continent code ("EU", "NA", …)
|
||||||
// - WAS → ADIF STATE code ("AL", …)
|
// - WAS → ADIF STATE code ("AL", …)
|
||||||
// - DDFM → "D06" (the award pattern captures the leading D)
|
// - DDFM → "D06" (the award pattern captures the leading D)
|
||||||
|
// - USA-CA → canonical "STATE/COUNTY" key (see award.USCountyKey)
|
||||||
func BuiltinRefs(code string) ([]Ref, bool) {
|
func BuiltinRefs(code string) ([]Ref, bool) {
|
||||||
switch code {
|
switch code {
|
||||||
case "DXCC":
|
case "DXCC":
|
||||||
@@ -29,6 +30,8 @@ func BuiltinRefs(code string) ([]Ref, bool) {
|
|||||||
return usStates().Refs, true
|
return usStates().Refs, true
|
||||||
case "DDFM":
|
case "DDFM":
|
||||||
return frenchDepartments(), true
|
return frenchDepartments(), true
|
||||||
|
case "USA-CA":
|
||||||
|
return usCounties(), true
|
||||||
}
|
}
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+112
-50
@@ -205,11 +205,12 @@ func (m *Manager) SetPTT(on bool) error {
|
|||||||
// the backend picks a default when it's empty. (Status-based colouring can be
|
// the backend picks a default when it's empty. (Status-based colouring can be
|
||||||
// driven later by setting Color per spot.)
|
// driven later by setting Color per spot.)
|
||||||
type SpotInfo struct {
|
type SpotInfo struct {
|
||||||
FreqHz int64
|
FreqHz int64
|
||||||
Callsign string
|
Callsign string
|
||||||
Mode string
|
Mode string
|
||||||
Color string
|
Color string
|
||||||
Comment string
|
Comment string
|
||||||
|
LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
|
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
|
||||||
@@ -258,25 +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"`
|
||||||
ATUStatus string `json:"atu_status,omitempty"`
|
TXFilterLow int `json:"tx_filter_low"` // TX filter low cut (Hz)
|
||||||
ATUMemories bool `json:"atu_memories"`
|
TXFilterHigh int `json:"tx_filter_high"` // TX filter high cut (Hz)
|
||||||
|
// Mic profiles (SmartSDR): the list of available profiles + the loaded one.
|
||||||
|
MicProfile string `json:"mic_profile,omitempty"`
|
||||||
|
MicProfiles []string `json:"mic_profiles,omitempty"`
|
||||||
|
ATUStatus string `json:"atu_status,omitempty"`
|
||||||
|
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
|
||||||
@@ -296,6 +302,31 @@ type FlexTXState struct {
|
|||||||
NRLevel int `json:"nr_level"`
|
NRLevel int `json:"nr_level"`
|
||||||
ANF bool `json:"anf"`
|
ANF bool `json:"anf"`
|
||||||
ANFLevel int `json:"anf_level"`
|
ANFLevel int `json:"anf_level"`
|
||||||
|
WNB bool `json:"wnb"`
|
||||||
|
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
|
||||||
|
// moving the slice. The offset survives the switch being turned off, so
|
||||||
|
// re-enabling restores it, exactly like the radio's own knob.
|
||||||
|
RIT bool `json:"rit"`
|
||||||
|
RITFreq int `json:"rit_freq"`
|
||||||
|
XIT bool `json:"xit"`
|
||||||
|
XITFreq int `json:"xit_freq"`
|
||||||
// CW / mode-specific controls.
|
// CW / mode-specific controls.
|
||||||
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
|
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
|
||||||
CWSpeed int `json:"cw_speed"`
|
CWSpeed int `json:"cw_speed"`
|
||||||
@@ -322,6 +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
|
||||||
Value float64 `json:"value"`
|
Value float64 `json:"value"`
|
||||||
Lo float64 `json:"lo"`
|
Lo float64 `json:"lo"`
|
||||||
Hi float64 `json:"hi"`
|
Hi float64 `json:"hi"`
|
||||||
@@ -336,6 +368,7 @@ type FlexController interface {
|
|||||||
SetRFPower(int) error
|
SetRFPower(int) error
|
||||||
SetTunePower(int) error
|
SetTunePower(int) error
|
||||||
SetTune(bool) error
|
SetTune(bool) error
|
||||||
|
SetTXInhibit(bool) error
|
||||||
SetVOX(bool) error
|
SetVOX(bool) error
|
||||||
SetVOXLevel(int) error
|
SetVOXLevel(int) error
|
||||||
SetVOXDelay(int) error
|
SetVOXDelay(int) error
|
||||||
@@ -344,6 +377,8 @@ type FlexController interface {
|
|||||||
SetMon(bool) error
|
SetMon(bool) error
|
||||||
SetMonLevel(int) error
|
SetMonLevel(int) error
|
||||||
SetMic(int) error
|
SetMic(int) error
|
||||||
|
SetTXFilter(low, high int) error // transmit-audio bandwidth (Hz)
|
||||||
|
SetMicProfile(string) error // load a SmartSDR mic profile by name
|
||||||
ATUStart() error
|
ATUStart() error
|
||||||
ATUBypass() error
|
ATUBypass() error
|
||||||
SetATUMemories(bool) error
|
SetATUMemories(bool) error
|
||||||
@@ -365,10 +400,37 @@ type FlexController interface {
|
|||||||
SetANFLevel(int) error
|
SetANFLevel(int) error
|
||||||
SetAPF(bool) error
|
SetAPF(bool) error
|
||||||
SetAPFLevel(int) error
|
SetAPFLevel(int) error
|
||||||
|
SetWNB(bool) 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
|
||||||
|
SetRITFreq(int) error
|
||||||
|
SetXIT(bool) error
|
||||||
|
SetXITFreq(int) error
|
||||||
// CW keyer + mode-specific controls.
|
// CW keyer + mode-specific controls.
|
||||||
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
|
||||||
@@ -412,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).
|
||||||
@@ -423,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).
|
||||||
@@ -477,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
|
||||||
|
|||||||
@@ -313,6 +313,8 @@ func ModelName(addr byte) string {
|
|||||||
return "IC-7300"
|
return "IC-7300"
|
||||||
case 0x98:
|
case 0x98:
|
||||||
return "IC-7610"
|
return "IC-7610"
|
||||||
|
case 0x7C:
|
||||||
|
return "IC-9100"
|
||||||
case 0xA2:
|
case 0xA2:
|
||||||
return "IC-9700"
|
return "IC-9700"
|
||||||
case 0xA4:
|
case 0xA4:
|
||||||
|
|||||||
+425
-28
@@ -30,14 +30,16 @@ 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)
|
||||||
amp flexAmp // external amplifier (PowerGenius XL) state
|
amp flexAmp // external amplifier (PowerGenius XL) state
|
||||||
|
micProfiles []string // available mic profiles (SmartSDR "profile mic list")
|
||||||
|
micProfile string // currently loaded mic profile
|
||||||
txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly)
|
txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly)
|
||||||
lastStateSig string // last logged derived-state signature (log only on change)
|
lastStateSig string // last logged derived-state signature (log only on change)
|
||||||
boundClientID string // GUI client (SmartSDR) we bound to; "" until bound. Binding lets this non-GUI client receive GUI-tied data (CW pitch/speed, break-in delay, RF power).
|
boundClientID string // GUI client (SmartSDR) we bound to; "" until bound. Binding lets this non-GUI client receive GUI-tied data (CW pitch/speed, break-in delay, RF power).
|
||||||
@@ -58,6 +60,7 @@ type Flex struct {
|
|||||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||||
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
||||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||||
|
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
||||||
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
||||||
|
|
||||||
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
||||||
@@ -85,6 +88,28 @@ type flexSlice struct {
|
|||||||
anfLevel int
|
anfLevel int
|
||||||
apf bool // CW audio peaking filter
|
apf bool // CW audio peaking filter
|
||||||
apfLevel int
|
apfLevel int
|
||||||
|
wnb bool // wideband noise blanker
|
||||||
|
wnbLevel int
|
||||||
|
// SmartSDR v4 DSP (8000/Aurora series). Key names per the FlexLib slice
|
||||||
|
// docs: lms_nr(NRL) / lms_anf(ANFL) / speex_nr(NRS) / rnnoise(RNN) /
|
||||||
|
// anft(ANFT) / nrf(NRF). Older radios never report them — dspV4 flips true
|
||||||
|
// the first time any of these keys appears, and gates the extra UI rows.
|
||||||
|
lmsNR bool // NRL — legacy LMS noise reduction
|
||||||
|
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)
|
filterLo int // slice filter low cut (Hz)
|
||||||
filterHi int // slice filter high 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)
|
||||||
@@ -100,6 +125,7 @@ type flexTX struct {
|
|||||||
tunePower int
|
tunePower int
|
||||||
tune bool
|
tune bool
|
||||||
transmitting bool // interlock state == TRANSMITTING
|
transmitting bool // interlock state == TRANSMITTING
|
||||||
|
inhibit bool // transmit inhibited (e.g. while a motorized antenna moves)
|
||||||
voxEnable bool
|
voxEnable bool
|
||||||
voxLevel int
|
voxLevel int
|
||||||
voxDelay int
|
voxDelay int
|
||||||
@@ -108,6 +134,9 @@ type flexTX struct {
|
|||||||
mon bool
|
mon bool
|
||||||
monLevel int
|
monLevel int
|
||||||
micLevel int
|
micLevel int
|
||||||
|
filterLow int // TX filter low 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).
|
||||||
@@ -133,6 +162,7 @@ type meterInfo struct {
|
|||||||
src string // SLC (slice), TX-, COD, RAD, AMP…
|
src string // SLC (slice), TX-, COD, RAD, AMP…
|
||||||
name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B…
|
name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B…
|
||||||
unit string // dbm, dbfs, swr, volts, degc, watts…
|
unit string // dbm, dbfs, swr, volts, degc, watts…
|
||||||
|
slc int // for src=SLC meters, the slice index (the ".num" field); -1 otherwise
|
||||||
lo float64
|
lo float64
|
||||||
hi float64
|
hi float64
|
||||||
}
|
}
|
||||||
@@ -151,7 +181,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
|||||||
return &Flex{
|
return &Flex{
|
||||||
host: strings.TrimSpace(host), port: port,
|
host: strings.TrimSpace(host), port: port,
|
||||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, pendingSplit: map[int]bool{},
|
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||||
}
|
}
|
||||||
@@ -209,6 +239,8 @@ func (f *Flex) Connect() error {
|
|||||||
f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby
|
f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby
|
||||||
f.send("sub radio all") // radio-wide incl. interlock (TX/RX state)
|
f.send("sub radio all") // radio-wide incl. interlock (TX/RX state)
|
||||||
f.send("sub cwx all") // CWX: the LIVE CW speed/pitch/break-in (transmit holds only a static default)
|
f.send("sub cwx all") // CWX: the LIVE CW speed/pitch/break-in (transmit holds only a static default)
|
||||||
|
f.send("sub profile all") // mic/global/tx profiles (for the mic-profile dropdown)
|
||||||
|
f.send("profile mic info") // request the current mic profile list + selection
|
||||||
f.send("sub client all") // learn the GUI client (SmartSDR) so we can bind to it (below)
|
f.send("sub client all") // learn the GUI client (SmartSDR) so we can bind to it (below)
|
||||||
f.startMeters(conn) // open the UDP VITA-49 stream for live meters
|
f.startMeters(conn) // open the UDP VITA-49 stream for live meters
|
||||||
if f.spotsEnabled {
|
if f.spotsEnabled {
|
||||||
@@ -344,6 +376,7 @@ func (f *Flex) reader(conn net.Conn) {
|
|||||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||||
f.spotCall[idx] = call
|
f.spotCall[idx] = call
|
||||||
f.spotIdx[idx] = true
|
f.spotIdx[idx] = true
|
||||||
|
f.spotByCall[strings.ToUpper(call)] = idx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A successful "slice create" for split returns the new slice's index;
|
// A successful "slice create" for split returns the new slice's index;
|
||||||
@@ -415,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":
|
||||||
@@ -445,6 +480,12 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay)
|
f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay)
|
||||||
case "mic_level", "miclevel":
|
case "mic_level", "miclevel":
|
||||||
f.tx.micLevel = atoiDefault(val, f.tx.micLevel)
|
f.tx.micLevel = atoiDefault(val, f.tx.micLevel)
|
||||||
|
// TX filter: the transmit STATUS reports the passband as lo/hi (the
|
||||||
|
// SET command is filter_low/filter_high — a SmartSDR quirk).
|
||||||
|
case "lo", "filter_low":
|
||||||
|
f.tx.filterLow = atoiDefault(val, f.tx.filterLow)
|
||||||
|
case "hi", "filter_high":
|
||||||
|
f.tx.filterHigh = atoiDefault(val, f.tx.filterHigh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
@@ -526,6 +567,35 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
// Mic-profile object — "profile mic list=A^B^C" (available profiles) and
|
||||||
|
// "profile mic current=<name>" (loaded one). Names can contain spaces, so
|
||||||
|
// values are taken from the raw payload after the key. Logged once so the
|
||||||
|
// exact field names are confirmable on real hardware.
|
||||||
|
if len(fields) >= 2 && fields[0] == "profile" && fields[1] == "mic" {
|
||||||
|
debugLog.Printf("Flex: profile status: %s", payload)
|
||||||
|
if i := strings.Index(payload, "list="); i >= 0 {
|
||||||
|
var profs []string
|
||||||
|
for _, p := range strings.Split(payload[i+len("list="):], "^") {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
profs = append(profs, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.micProfiles = profs
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
// The loaded profile arrives as current=<name> (some firmwares:
|
||||||
|
// selection=<name>); accept either.
|
||||||
|
for _, key := range []string{"current=", "selection="} {
|
||||||
|
if i := strings.Index(payload, key); i >= 0 {
|
||||||
|
cur := strings.TrimSpace(payload[i+len(key):])
|
||||||
|
f.mu.Lock()
|
||||||
|
f.micProfile = cur
|
||||||
|
f.mu.Unlock()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Interlock object — transmit state (RECEIVE / TRANSMITTING / …).
|
// Interlock object — transmit state (RECEIVE / TRANSMITTING / …).
|
||||||
if len(fields) >= 1 && fields[0] == "interlock" {
|
if len(fields) >= 1 && fields[0] == "interlock" {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
@@ -596,7 +666,7 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
// One meter per token; its fields are '#'-separated:
|
// One meter per token; its fields are '#'-separated:
|
||||||
// "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…".
|
// "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…".
|
||||||
num := -1
|
num := -1
|
||||||
var mi meterInfo
|
mi := meterInfo{slc: -1}
|
||||||
for _, sub := range strings.Split(tok, "#") {
|
for _, sub := range strings.Split(tok, "#") {
|
||||||
key, val, ok := splitKV(sub)
|
key, val, ok := splitKV(sub)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -616,6 +686,12 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
mi.src = val
|
mi.src = val
|
||||||
case "nam":
|
case "nam":
|
||||||
mi.name = val
|
mi.name = val
|
||||||
|
case "num":
|
||||||
|
// For a slice (SLC) meter this field is the slice index —
|
||||||
|
// how we tell slice A's S-meter from slice B's.
|
||||||
|
if v, e := strconv.Atoi(strings.TrimSpace(val)); e == nil {
|
||||||
|
mi.slc = v
|
||||||
|
}
|
||||||
case "unit", "units":
|
case "unit", "units":
|
||||||
mi.unit = val
|
mi.unit = val
|
||||||
case "low", "lo":
|
case "low", "lo":
|
||||||
@@ -630,6 +706,7 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
old, seen := f.meterMeta[num]
|
old, seen := f.meterMeta[num]
|
||||||
if !seen {
|
if !seen {
|
||||||
newIDs = append(newIDs, num)
|
newIDs = append(newIDs, num)
|
||||||
|
old.slc = -1
|
||||||
}
|
}
|
||||||
if mi.src != "" {
|
if mi.src != "" {
|
||||||
old.src = mi.src
|
old.src = mi.src
|
||||||
@@ -640,6 +717,9 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
if mi.unit != "" {
|
if mi.unit != "" {
|
||||||
old.unit = mi.unit
|
old.unit = mi.unit
|
||||||
}
|
}
|
||||||
|
if mi.slc >= 0 {
|
||||||
|
old.slc = mi.slc
|
||||||
|
}
|
||||||
if mi.lo != 0 {
|
if mi.lo != 0 {
|
||||||
old.lo = mi.lo
|
old.lo = mi.lo
|
||||||
}
|
}
|
||||||
@@ -651,7 +731,7 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
for _, id := range newIDs {
|
for _, id := range newIDs {
|
||||||
mi := f.meterMeta[id]
|
mi := f.meterMeta[id]
|
||||||
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s → sub", id, mi.src, mi.name, mi.unit)
|
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s lo=%g hi=%g → sub", id, mi.src, mi.name, mi.unit, mi.lo, mi.hi)
|
||||||
f.subscribeMeter(id)
|
f.subscribeMeter(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -744,6 +824,40 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
s.apf = val == "1"
|
s.apf = val == "1"
|
||||||
case "apf_level":
|
case "apf_level":
|
||||||
s.apfLevel = atoiDefault(val, s.apfLevel)
|
s.apfLevel = atoiDefault(val, s.apfLevel)
|
||||||
|
case "wnb":
|
||||||
|
s.wnb = val == "1"
|
||||||
|
case "wnb_level":
|
||||||
|
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":
|
||||||
|
s.rit = val == "1"
|
||||||
|
case "rit_freq":
|
||||||
|
s.ritFreq = atoiDefault(val, s.ritFreq)
|
||||||
|
case "xit_on":
|
||||||
|
s.xit = val == "1"
|
||||||
|
case "xit_freq":
|
||||||
|
s.xitFreq = atoiDefault(val, s.xitFreq)
|
||||||
case "filter_lo":
|
case "filter_lo":
|
||||||
s.filterLo = atoiDefault(val, s.filterLo)
|
s.filterLo = atoiDefault(val, s.filterLo)
|
||||||
case "filter_hi":
|
case "filter_hi":
|
||||||
@@ -803,24 +917,44 @@ func (f *Flex) ReadState() (RigState, error) {
|
|||||||
// band) never hijacks the main frequency. Returns (-1, nil) when no slice is in
|
// band) never hijacks the main frequency. Returns (-1, nil) when no slice is in
|
||||||
// use. Caller holds f.mu.
|
// use. Caller holds f.mu.
|
||||||
func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
||||||
best, bestS := 1<<30, (*flexSlice)(nil)
|
// Iterate in ASCENDING index order — NEVER map-iteration order, which Go
|
||||||
for idx, s := range f.slices {
|
// randomises. When two slices transiently BOTH report active=1 (e.g. an
|
||||||
|
// external controller like DXHunter activates a slice on another band while
|
||||||
|
// ours still holds active, before SmartSDR sends active=0 to the old one),
|
||||||
|
// map order returned a RANDOM active slice each call → the operating frequency
|
||||||
|
// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
|
||||||
|
// Deterministic order = the lowest-indexed active slice wins, stably.
|
||||||
|
firstInUse := -1
|
||||||
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
|
s := f.slices[idx]
|
||||||
if !s.inUse {
|
if !s.inUse {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if firstInUse < 0 {
|
||||||
|
firstInUse = idx
|
||||||
|
}
|
||||||
if s.active {
|
if s.active {
|
||||||
return idx, s
|
return idx, s
|
||||||
}
|
}
|
||||||
if idx < best {
|
|
||||||
best, bestS = idx, s
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if bestS != nil {
|
if firstInUse >= 0 {
|
||||||
return best, bestS
|
return firstInUse, f.slices[firstInUse]
|
||||||
}
|
}
|
||||||
return -1, nil
|
return -1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortedSliceIdxLocked returns the slice indices in ascending order so every
|
||||||
|
// slice-selection helper is deterministic (map iteration is randomised). Caller
|
||||||
|
// holds f.mu.
|
||||||
|
func (f *Flex) sortedSliceIdxLocked() []int {
|
||||||
|
idxs := make([]int, 0, len(f.slices))
|
||||||
|
for idx := range f.slices {
|
||||||
|
idxs = append(idxs, idx)
|
||||||
|
}
|
||||||
|
sort.Ints(idxs)
|
||||||
|
return idxs
|
||||||
|
}
|
||||||
|
|
||||||
// activeSliceIndexLocked returns the slice index to send commands to (the main
|
// activeSliceIndexLocked returns the slice index to send commands to (the main
|
||||||
// slice, else 0). Caller holds f.mu.
|
// slice, else 0). Caller holds f.mu.
|
||||||
func (f *Flex) activeSliceIndexLocked() int {
|
func (f *Flex) activeSliceIndexLocked() int {
|
||||||
@@ -841,8 +975,8 @@ func sliceLetter(idx int) string {
|
|||||||
// txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil.
|
// txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil.
|
||||||
// Caller holds f.mu.
|
// Caller holds f.mu.
|
||||||
func (f *Flex) txSliceLocked() *flexSlice {
|
func (f *Flex) txSliceLocked() *flexSlice {
|
||||||
for _, s := range f.slices {
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
if s.inUse && s.tx {
|
if s := f.slices[idx]; s.inUse && s.tx {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -852,9 +986,11 @@ func (f *Flex) txSliceLocked() *flexSlice {
|
|||||||
// operatingLocked resolves the operator's slices: the MAIN (active) slice for the
|
// operatingLocked resolves the operator's slices: the MAIN (active) slice for the
|
||||||
// mode/display, and — ONLY for a GENUINE split — the RX and TX slices. Split is
|
// mode/display, and — ONLY for a GENUINE split — the RX and TX slices. Split is
|
||||||
// the tx-flagged slice PLUS a distinct in-use slice on the SAME band (different
|
// the tx-flagged slice PLUS a distinct in-use slice on the SAME band (different
|
||||||
// freq) — detected from the pair itself, NOT from which slice is active (the TX
|
// freq) AND the SAME split class (both PHONE, or both CW) — detected from the
|
||||||
// slice often steals focus right after "slice create", which must NOT read as
|
// pair itself, NOT from which slice is active (the TX slice often steals focus
|
||||||
// "no split"). A slice on another band is an independent receiver, ignored.
|
// right after "slice create", which must NOT read as "no split"). A slice on
|
||||||
|
// another band is an independent receiver, ignored; so is a same-band slice in a
|
||||||
|
// different mode (SSB + FT8) or a data mode (FT8/FT4/RTTY never split).
|
||||||
// Caller holds f.mu.
|
// Caller holds f.mu.
|
||||||
func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
|
func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
|
||||||
_, main = f.mainSliceLocked()
|
_, main = f.mainSliceLocked()
|
||||||
@@ -866,20 +1002,31 @@ func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
|
|||||||
if bt == "" {
|
if bt == "" {
|
||||||
return main, nil, nil
|
return main, nil, nil
|
||||||
}
|
}
|
||||||
// RX = the active slice when it's a distinct same-band slice, else the first
|
// Split only applies to PHONE/CW; a data-mode TX slice never splits.
|
||||||
// other in-use same-band slice.
|
ct := flexSplitClass(txS.mode)
|
||||||
if main != nil && main != txS && main.freqHz != txS.freqHz && BandFromHz(main.freqHz) == bt {
|
if ct == "" {
|
||||||
|
return main, nil, nil
|
||||||
|
}
|
||||||
|
// A split partner is an in-use, same-band slice at a different freq in the
|
||||||
|
// SAME split class (so SSB + FT8 on one band is NOT a split).
|
||||||
|
sameSplit := func(s *flexSlice) bool {
|
||||||
|
return s != nil && s.inUse && s != txS && s.freqHz != txS.freqHz &&
|
||||||
|
BandFromHz(s.freqHz) == bt && flexSplitClass(s.mode) == ct
|
||||||
|
}
|
||||||
|
// RX = the active slice when it qualifies, else the first other qualifying
|
||||||
|
// same-band slice.
|
||||||
|
if sameSplit(main) {
|
||||||
rx = main
|
rx = main
|
||||||
} else {
|
} else {
|
||||||
for _, s := range f.slices {
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
if s.inUse && s != txS && s.freqHz != txS.freqHz && BandFromHz(s.freqHz) == bt {
|
if s := f.slices[idx]; sameSplit(s) {
|
||||||
rx = s
|
rx = s
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if rx == nil {
|
if rx == nil {
|
||||||
return main, nil, nil // tx slice alone (simplex) → not split
|
return main, nil, nil // tx slice alone (simplex) or no same-mode partner → not split
|
||||||
}
|
}
|
||||||
return main, rx, txS
|
return main, rx, txS
|
||||||
}
|
}
|
||||||
@@ -927,6 +1074,15 @@ func (f *Flex) SetFrequency(hz int64) error {
|
|||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
idx := f.activeSliceIndexLocked()
|
idx := f.activeSliceIndexLocked()
|
||||||
connected := f.conn != nil
|
connected := f.conn != nil
|
||||||
|
// Optimistically update the active slice's cached freq NOW, before the radio
|
||||||
|
// echoes the slice status back. Otherwise ReadState/FlexState keep reporting
|
||||||
|
// the OLD freq for the round-trip: the top display (optimistic liveFreqHz)
|
||||||
|
// jumped to the new band while the slice cache — which the FlexPanel and the
|
||||||
|
// Ultrabeam follow loop read — still showed the old one, so the antenna chased
|
||||||
|
// the stale value. The real echo confirms/corrects this a moment later.
|
||||||
|
if s := f.slices[idx]; s != nil {
|
||||||
|
s.freqHz = hz
|
||||||
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if !connected {
|
if !connected {
|
||||||
return fmt.Errorf("flex: not connected")
|
return fmt.Errorf("flex: not connected")
|
||||||
@@ -952,6 +1108,13 @@ func (f *Flex) SetMode(mode string) error {
|
|||||||
if fm == "" {
|
if fm == "" {
|
||||||
return fmt.Errorf("flex: unsupported mode %q", mode)
|
return fmt.Errorf("flex: unsupported mode %q", mode)
|
||||||
}
|
}
|
||||||
|
// Optimistically cache the new mode too (same reasoning as SetFrequency) so the
|
||||||
|
// panel reflects it immediately instead of lagging the radio's echo.
|
||||||
|
f.mu.Lock()
|
||||||
|
if s := f.slices[idx]; s != nil {
|
||||||
|
s.mode = fm
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
// "slice s <rx> mode=<m>" — set command per the SmartSDR API.
|
// "slice s <rx> mode=<m>" — set command per the SmartSDR API.
|
||||||
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
|
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
|
||||||
return nil
|
return nil
|
||||||
@@ -975,8 +1138,30 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
|||||||
if color == "" {
|
if color == "" {
|
||||||
color = "#FFFFA500" // opaque orange default
|
color = "#FFFFA500" // opaque orange default
|
||||||
}
|
}
|
||||||
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=1800 trigger_action=Tune timestamp=%d",
|
life := s.LifetimeSec
|
||||||
float64(s.FreqHz)/1e6, call, color, time.Now().Unix())
|
if life <= 0 {
|
||||||
|
life = 1800 // default 30 min (cluster spots)
|
||||||
|
}
|
||||||
|
// De-dupe by callsign: WSJT decodes re-fire every cycle, so a station already
|
||||||
|
// spotted gets its previous spot removed first — one live spot per call,
|
||||||
|
// refreshed, instead of a pile that all expire independently. NB: capture the
|
||||||
|
// old index under the lock but send OUTSIDE it — f.send() takes f.mu itself,
|
||||||
|
// and Go mutexes aren't reentrant (calling send while locked deadlocks the
|
||||||
|
// whole Flex goroutine → the radio drops OFFLINE).
|
||||||
|
upperCall := strings.ToUpper(s.Callsign)
|
||||||
|
f.mu.Lock()
|
||||||
|
old, hadOld := f.spotByCall[upperCall]
|
||||||
|
if hadOld {
|
||||||
|
delete(f.spotByCall, upperCall)
|
||||||
|
delete(f.spotCall, old)
|
||||||
|
delete(f.spotIdx, old)
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if hadOld {
|
||||||
|
f.send(fmt.Sprintf("spot remove %d", old))
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
|
||||||
|
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
|
||||||
if m := flexEncode(s.Mode); m != "" {
|
if m := flexEncode(s.Mode); m != "" {
|
||||||
cmd += " mode=" + m
|
cmd += " mode=" + m
|
||||||
}
|
}
|
||||||
@@ -1022,6 +1207,7 @@ func (f *Flex) ClearSpots() error {
|
|||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.spotIdx = map[int]bool{}
|
f.spotIdx = map[int]bool{}
|
||||||
f.spotCall = map[int]string{}
|
f.spotCall = map[int]string{}
|
||||||
|
f.spotByCall = map[string]int{}
|
||||||
connected := f.conn != nil
|
connected := f.conn != nil
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if !connected {
|
if !connected {
|
||||||
@@ -1127,6 +1313,11 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
Mon: f.tx.mon,
|
Mon: f.tx.mon,
|
||||||
MonLevel: f.tx.monLevel,
|
MonLevel: f.tx.monLevel,
|
||||||
MicLevel: f.tx.micLevel,
|
MicLevel: f.tx.micLevel,
|
||||||
|
TXFilterLow: f.tx.filterLow,
|
||||||
|
TXFilterHigh: f.tx.filterHigh,
|
||||||
|
TXDAX: f.tx.dax,
|
||||||
|
MicProfile: f.micProfile,
|
||||||
|
MicProfiles: f.micProfiles,
|
||||||
ATUStatus: f.tx.atuStatus,
|
ATUStatus: f.tx.atuStatus,
|
||||||
ATUMemories: f.tx.atuMemories,
|
ATUMemories: f.tx.atuMemories,
|
||||||
// CW keyer (defaults applied so the sliders show sane values pre-read).
|
// CW keyer (defaults applied so the sliders show sane values pre-read).
|
||||||
@@ -1177,6 +1368,24 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
st.ANFLevel = rx.anfLevel
|
st.ANFLevel = rx.anfLevel
|
||||||
st.APF = rx.apf
|
st.APF = rx.apf
|
||||||
st.APFLevel = rx.apfLevel
|
st.APFLevel = rx.apfLevel
|
||||||
|
st.WNB = rx.wnb
|
||||||
|
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.RITFreq = rx.ritFreq
|
||||||
|
st.XIT = rx.xit
|
||||||
|
st.XITFreq = rx.xitFreq
|
||||||
st.FilterLo = rx.filterLo
|
st.FilterLo = rx.filterLo
|
||||||
st.FilterHi = rx.filterHi
|
st.FilterHi = rx.filterHi
|
||||||
st.RXAnt = rx.rxAnt
|
st.RXAnt = rx.rxAnt
|
||||||
@@ -1201,7 +1410,7 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll
|
sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
mi := f.meterMeta[id]
|
mi := f.meterMeta[id]
|
||||||
st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi})
|
st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Slice: mi.slc, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return st
|
return st
|
||||||
@@ -1241,10 +1450,40 @@ 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":
|
||||||
rx.txAnt = fmt.Sprint(val)
|
rx.txAnt = fmt.Sprint(val)
|
||||||
|
case "rit_on":
|
||||||
|
rx.rit = val == "1"
|
||||||
|
case "rit_freq":
|
||||||
|
rx.ritFreq = toInt(val)
|
||||||
|
case "xit_on":
|
||||||
|
rx.xit = val == "1"
|
||||||
|
case "xit_freq":
|
||||||
|
rx.xitFreq = toInt(val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
@@ -1352,9 +1591,63 @@ 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
|
||||||
|
// 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
|
||||||
|
// radio's own knob.
|
||||||
|
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) 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)) }
|
||||||
|
|
||||||
|
// clampOffset keeps a RIT/XIT offset inside what SmartSDR accepts (±99 999 Hz).
|
||||||
|
func clampOffset(hz int) int {
|
||||||
|
if hz > 99999 {
|
||||||
|
return 99999
|
||||||
|
}
|
||||||
|
if hz < -99999 {
|
||||||
|
return -99999
|
||||||
|
}
|
||||||
|
return hz
|
||||||
|
}
|
||||||
|
|
||||||
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) SetWNBLevel(l int) error { return f.sendSlice("wnb_level", clampLevel(l)) }
|
||||||
|
|
||||||
// ── CW keyer controls (top-level "cw" commands) ──
|
// ── CW keyer controls (top-level "cw" commands) ──
|
||||||
|
|
||||||
func (f *Flex) SetCWSpeed(wpm int) error {
|
func (f *Flex) SetCWSpeed(wpm int) error {
|
||||||
@@ -1396,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
|
||||||
@@ -1524,6 +1848,23 @@ func (f *Flex) SetTune(on bool) error {
|
|||||||
return f.txSet(cmd, "tune", func(t *flexTX) { t.tune = on })
|
return f.txSet(cmd, "tune", func(t *flexTX) { t.tune = on })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTXInhibit blocks (on=true) or allows transmission at the radio. Used to keep
|
||||||
|
// the operator from keying while a motorized antenna's elements are moving —
|
||||||
|
// SmartSDR refuses to transmit while inhibit is set, so it holds even against a
|
||||||
|
// footswitch or an external keyer, which a software PTT block could not.
|
||||||
|
//
|
||||||
|
// It also labels the interlock reason "OpsLog" so SmartSDR shows WHY TX is
|
||||||
|
// blocked. The reason is best-effort (a separate interlock object); the inhibit
|
||||||
|
// is what actually blocks TX, so a rig that ignores the reason still stays safe.
|
||||||
|
func (f *Flex) SetTXInhibit(on bool) error {
|
||||||
|
if on {
|
||||||
|
f.send("interlock set reason=OpsLog")
|
||||||
|
} else {
|
||||||
|
f.send("interlock set reason=")
|
||||||
|
}
|
||||||
|
return f.txSet("transmit set inhibit="+boolFlex(on), "inhibit", func(t *flexTX) { t.inhibit = on })
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Flex) SetVOX(on bool) error {
|
func (f *Flex) SetVOX(on bool) error {
|
||||||
return f.txSet("transmit set vox_enable="+boolFlex(on), "vox_enable", func(t *flexTX) { t.voxEnable = on })
|
return f.txSet("transmit set vox_enable="+boolFlex(on), "vox_enable", func(t *flexTX) { t.voxEnable = on })
|
||||||
}
|
}
|
||||||
@@ -1571,6 +1912,45 @@ func (f *Flex) SetMic(l int) error {
|
|||||||
return f.txSet(fmt.Sprintf("transmit set miclevel=%d", l), "mic_level", func(t *flexTX) { t.micLevel = l })
|
return f.txSet(fmt.Sprintf("transmit set miclevel=%d", l), "mic_level", func(t *flexTX) { t.micLevel = l })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTXFilter sets the transmit-audio bandwidth (low + high cut, in Hz). SmartSDR
|
||||||
|
// clamps to legal values per mode; we just pass them through in one command so
|
||||||
|
// both edges move together.
|
||||||
|
func (f *Flex) SetTXFilter(low, high int) error {
|
||||||
|
if low < 0 {
|
||||||
|
low = 0
|
||||||
|
}
|
||||||
|
if high < low {
|
||||||
|
high = low
|
||||||
|
}
|
||||||
|
// Guard both status field names (the echo comes back as lo/hi, not
|
||||||
|
// filter_low/high) so the radio's lagging echo doesn't snap the inputs back.
|
||||||
|
f.mu.Lock()
|
||||||
|
f.txSetAt["hi"] = time.Now()
|
||||||
|
f.mu.Unlock()
|
||||||
|
return f.txSet(fmt.Sprintf("transmit set filter_low=%d filter_high=%d", low, high), "lo",
|
||||||
|
func(t *flexTX) { t.filterLow, t.filterHigh = low, high })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMicProfile loads a SmartSDR mic profile by name (quoted, as names may hold
|
||||||
|
// spaces). Optimistically caches the selection.
|
||||||
|
func (f *Flex) SetMicProfile(name string) error {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("flex: empty mic profile")
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
connected := f.conn != nil
|
||||||
|
if connected {
|
||||||
|
f.micProfile = name
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
f.send(fmt.Sprintf("profile mic load \"%s\"", name))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Flex) ATUStart() error {
|
func (f *Flex) ATUStart() error {
|
||||||
if !f.connected() {
|
if !f.connected() {
|
||||||
return fmt.Errorf("flex: not connected")
|
return fmt.Errorf("flex: not connected")
|
||||||
@@ -1790,6 +2170,23 @@ func parseFloatDefault(s string, def float64) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// flexModeToADIF maps a Flex slice mode to a generic ADIF mode.
|
// flexModeToADIF maps a Flex slice mode to a generic ADIF mode.
|
||||||
|
// flexSplitClass groups a raw Flex slice mode into a split-capable class. A
|
||||||
|
// genuine split (one RX freq, one TX freq, SAME mode) only makes sense for PHONE
|
||||||
|
// and CW pileups. Data modes (FT8/FT4/RTTY all report as DIGU/DIGL/RTTY on a
|
||||||
|
// Flex) and digital voice never operate split, so they return "" (not
|
||||||
|
// split-capable). Two same-band slices whose classes differ (e.g. SSB + FT8) are
|
||||||
|
// independent operations, not a split.
|
||||||
|
func flexSplitClass(rawMode string) string {
|
||||||
|
switch flexModeToADIF(rawMode) {
|
||||||
|
case "SSB", "AM", "FM":
|
||||||
|
return "PHONE"
|
||||||
|
case "CW":
|
||||||
|
return "CW"
|
||||||
|
default: // DATA, RTTY, DIGITALVOICE, "" → never split
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func flexModeToADIF(m string) string {
|
func flexModeToADIF(m string) string {
|
||||||
switch strings.ToUpper(strings.TrimSpace(m)) {
|
switch strings.ToUpper(strings.TrimSpace(m)) {
|
||||||
case "USB", "LSB":
|
case "USB", "LSB":
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A genuine split is two same-band slices in the SAME split class (both PHONE or
|
||||||
|
// both CW). Two same-band slices in different modes (SSB + FT8), or in a data
|
||||||
|
// mode (FT8/FT4/RTTY = DIGU/DIGL), must NOT read as split.
|
||||||
|
func TestOperatingSplitMode(t *testing.T) {
|
||||||
|
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
|
||||||
|
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
slices map[int]*flexSlice
|
||||||
|
wantSplit bool
|
||||||
|
}{
|
||||||
|
{"phone split (both USB)", map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", true, false),
|
||||||
|
1: mk(14_200_000, "USB", false, true),
|
||||||
|
}, true},
|
||||||
|
{"CW split (both CW)", map[int]*flexSlice{
|
||||||
|
0: mk(7_010_000, "CW", true, false),
|
||||||
|
1: mk(7_025_000, "CW", false, true),
|
||||||
|
}, true},
|
||||||
|
{"SSB + FT8 same band → not split", map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", true, false),
|
||||||
|
1: mk(14_074_000, "DIGU", false, true),
|
||||||
|
}, false},
|
||||||
|
{"both FT8 (DIGU) same band → not split", map[int]*flexSlice{
|
||||||
|
0: mk(14_074_000, "DIGU", true, false),
|
||||||
|
1: mk(14_080_000, "DIGU", false, true),
|
||||||
|
}, false},
|
||||||
|
{"different bands → not split", map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", true, false),
|
||||||
|
1: mk(7_100_000, "LSB", false, true),
|
||||||
|
}, false},
|
||||||
|
{"simplex (single slice) → not split", map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", true, true),
|
||||||
|
}, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
f := &Flex{slices: c.slices}
|
||||||
|
_, rx, tx := f.operatingLocked()
|
||||||
|
gotSplit := rx != nil && tx != nil
|
||||||
|
if gotSplit != c.wantSplit {
|
||||||
|
t.Errorf("%s: split=%v, want %v", c.name, gotSplit, c.wantSplit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
// icomaudio.go — the NETWORK AUDIO stream (UDP 50003) for the Icom LAN protocol.
|
||||||
|
// It is the third stream alongside control (50001) and CI-V (50002): once the
|
||||||
|
// control login + conninfo (with rxenable=1) authorize audio, the rig streams RX
|
||||||
|
// audio here as data packets. This file dials/handshakes/keeps-alive that socket
|
||||||
|
// exactly like the CI-V stream (icomnet.go) — those parts are byte-for-byte the
|
||||||
|
// PROVEN transport — and hands each received audio payload to a sink callback
|
||||||
|
// (the app decodes it via an audio.Codec and plays it through the RX monitor).
|
||||||
|
//
|
||||||
|
// Reuses icomnet.go's helpers (icnCtrl, icnHandshake, icnPingReply, icnRecv,
|
||||||
|
// icnLocalID, icnLE) and the same seq/retransmit discipline.
|
||||||
|
//
|
||||||
|
// ⚠️ PAYLOAD OFFSET PENDING ON-RIG VERIFICATION. The stream framing (handshake,
|
||||||
|
// ping, idle, retransmit, common 16-byte header) is identical to CI-V and proven.
|
||||||
|
// The AUDIO data packet's inner layout — where the PCM starts and the datalen
|
||||||
|
// field — is reconstructed from wfview's audio_packet (ident@0x10, datalen@0x12,
|
||||||
|
// sendseq@0x14, audio@0x16) but NOT yet confirmed against a real 50003 capture.
|
||||||
|
// audioPump logs the first few raw packets (icaDumpFirst) so the offset can be
|
||||||
|
// confirmed/corrected on the first on-rig test without a packet capture, the same
|
||||||
|
// way the CI-V/scope framing was iterated. Nothing here can destabilize CAT: the
|
||||||
|
// audio stream is opt-in and entirely separate from control/CI-V.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// icaAudioOffset is where the PCM payload begins inside an audio data packet
|
||||||
|
// (wfview audio_packet: 16-byte common header + ident@0x10 + datalen@0x12 +
|
||||||
|
// sendseq@0x14 → audio@0x16). Isolated as a const so a capture-confirmed change
|
||||||
|
// is a one-line edit.
|
||||||
|
const icaAudioOffset = 0x16
|
||||||
|
|
||||||
|
// icaDumpFirst is how many initial audio packets to hex-dump to the debug log for
|
||||||
|
// offset verification. After the layout is confirmed on a real rig this can go to
|
||||||
|
// 0 (or the const above corrected).
|
||||||
|
const icaDumpFirst = 6
|
||||||
|
|
||||||
|
// icomAudio is the connected audio stream. RX only for now (Phase 4); TX (Phase
|
||||||
|
// 5) will add an encode+send path mirroring icomNet.Write.
|
||||||
|
type icomAudio struct {
|
||||||
|
conn *net.UDPConn
|
||||||
|
aID, aRemote uint32
|
||||||
|
|
||||||
|
sink func([]byte) // receives each raw audio payload (app decodes + plays)
|
||||||
|
|
||||||
|
// Receive-side retransmit (audio is a heavy stream, like the scope): track the
|
||||||
|
// rig's data-packet send seq and ask it to resend gaps, or the rig drops the
|
||||||
|
// session. Same mechanism as icomNet. Owned solely by audioPump → no lock.
|
||||||
|
rxHaveSeq bool
|
||||||
|
rxLastSeq uint16
|
||||||
|
rxMissing map[uint16]int
|
||||||
|
|
||||||
|
dumped int // packets hex-dumped so far (≤ icaDumpFirst)
|
||||||
|
lastRx atomic.Int64 // UnixNano of last packet (liveness)
|
||||||
|
|
||||||
|
done chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *icomAudio) markRx() { a.lastRx.Store(time.Now().UnixNano()) }
|
||||||
|
|
||||||
|
// Close tears the audio stream down (disconnect a few times; UDP is lossy).
|
||||||
|
func (a *icomAudio) Close() {
|
||||||
|
a.closeOnce.Do(func() {
|
||||||
|
close(a.done)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
_, _ = a.conn.Write(icnCtrl(0x05, 0, a.aID, a.aRemote)) // disconnect
|
||||||
|
time.Sleep(15 * time.Millisecond)
|
||||||
|
}
|
||||||
|
_ = a.conn.Close()
|
||||||
|
debugLog.Printf("icom audio: stream closed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialIcomAudio opens the audio UDP stream to rig:50003, binding LOCAL :50003
|
||||||
|
// (mirroring the civ stream's local :50002). The control conninfo (rxenable=1,
|
||||||
|
// audioport=50003) must already have authorized it. sink receives each raw audio
|
||||||
|
// payload. cancel aborts a slow dial (Stop/Start).
|
||||||
|
func dialIcomAudio(host string, sink func([]byte), cancel <-chan struct{}) (*icomAudio, error) {
|
||||||
|
araddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50003"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
conn, err := net.DialUDP("udp4", &net.UDPAddr{Port: 50003}, araddr)
|
||||||
|
if err != nil {
|
||||||
|
debugLog.Printf("icom audio: cannot bind local :50003 (Remote Utility running?): %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
aID := icnLocalID(conn)
|
||||||
|
aRemote, err := icnHandshake(conn, aID, cancel)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
debugLog.Printf("icom audio: handshake FAILED: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = conn.SetReadBuffer(1 << 20)
|
||||||
|
a := &icomAudio{
|
||||||
|
conn: conn, aID: aID, aRemote: aRemote,
|
||||||
|
sink: sink,
|
||||||
|
rxMissing: make(map[uint16]int),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
a.markRx()
|
||||||
|
debugLog.Printf("icom audio: stream up (rig id 0x%08X) — awaiting RX audio", aRemote)
|
||||||
|
go a.audioPump()
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// audioPump drains the audio socket: replies to pings, sends idle keepalives,
|
||||||
|
// requests retransmits for lost packets, and hands each audio payload to sink.
|
||||||
|
func (a *icomAudio) audioPump() {
|
||||||
|
buf := make([]byte, 8192)
|
||||||
|
lastIdle := time.Now()
|
||||||
|
lastReq := time.Now()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-a.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
_ = a.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
|
||||||
|
if k, err := a.conn.Read(buf); err == nil && k >= 16 {
|
||||||
|
a.markRx()
|
||||||
|
switch typ := icnLE.Uint16(buf[4:]); {
|
||||||
|
case typ == 0x07: // ping
|
||||||
|
_, _ = a.conn.Write(icnPingReply(buf[:k], a.aID, a.aRemote))
|
||||||
|
case typ == 0x01: // retransmit request from the rig (we send no tracked audio yet)
|
||||||
|
case typ == 0x05: // rig-initiated disconnect
|
||||||
|
debugLog.Printf("icom audio: rig sent DISCONNECT — audio stream dropped by the rig")
|
||||||
|
case typ == 0x00 && k > icaAudioOffset: // audio data packet
|
||||||
|
a.trackRxSeq(icnLE.Uint16(buf[6:]))
|
||||||
|
if a.dumped < icaDumpFirst {
|
||||||
|
a.dumped++
|
||||||
|
debugLog.Printf("icom audio raw #%d: len=%d head=% X", a.dumped, k, buf[:min(icaAudioOffset+8, k)])
|
||||||
|
}
|
||||||
|
if a.sink != nil {
|
||||||
|
payload := append([]byte(nil), buf[icaAudioOffset:k]...)
|
||||||
|
a.sink(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if time.Since(lastIdle) > 100*time.Millisecond {
|
||||||
|
_, _ = a.conn.Write(icnCtrl(0x00, 0, a.aID, a.aRemote))
|
||||||
|
lastIdle = time.Now()
|
||||||
|
}
|
||||||
|
if time.Since(lastReq) > 100*time.Millisecond {
|
||||||
|
a.sendRetransmitReq()
|
||||||
|
lastReq = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackRxSeq / sendRetransmitReq mirror icomNet's receive-side retransmit exactly
|
||||||
|
// (audio is as loss-sensitive as the scope stream). Duplicated deliberately so
|
||||||
|
// the audio stream owns its own seq state with no shared locking.
|
||||||
|
func (a *icomAudio) trackRxSeq(seq uint16) {
|
||||||
|
if !a.rxHaveSeq {
|
||||||
|
a.rxHaveSeq = true
|
||||||
|
a.rxLastSeq = seq
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch d := int16(seq - a.rxLastSeq); {
|
||||||
|
case d == 0:
|
||||||
|
case d < 0:
|
||||||
|
delete(a.rxMissing, seq)
|
||||||
|
case d == 1:
|
||||||
|
a.rxLastSeq = seq
|
||||||
|
case int(d) <= icnMaxMissing:
|
||||||
|
for f := a.rxLastSeq + 1; f != seq; f++ {
|
||||||
|
a.rxMissing[f] = 0
|
||||||
|
}
|
||||||
|
a.rxLastSeq = seq
|
||||||
|
default:
|
||||||
|
a.rxMissing = make(map[uint16]int)
|
||||||
|
a.rxLastSeq = seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *icomAudio) sendRetransmitReq() {
|
||||||
|
if len(a.rxMissing) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(a.rxMissing) > icnMaxMissing {
|
||||||
|
a.rxMissing = make(map[uint16]int)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var seqs []uint16
|
||||||
|
for s, cnt := range a.rxMissing {
|
||||||
|
if cnt >= 4 {
|
||||||
|
delete(a.rxMissing, s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.rxMissing[s] = cnt + 1
|
||||||
|
seqs = append(seqs, s)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(seqs) == 0:
|
||||||
|
return
|
||||||
|
case len(seqs) == 1:
|
||||||
|
_, _ = a.conn.Write(icnCtrl(0x01, seqs[0], a.aID, a.aRemote))
|
||||||
|
default:
|
||||||
|
b := make([]byte, 16+4*len(seqs))
|
||||||
|
icnLE.PutUint32(b[0:], uint32(len(b)))
|
||||||
|
icnLE.PutUint16(b[4:], 0x01)
|
||||||
|
icnLE.PutUint32(b[8:], a.aID)
|
||||||
|
icnLE.PutUint32(b[12:], a.aRemote)
|
||||||
|
off := 16
|
||||||
|
for _, s := range seqs {
|
||||||
|
icnLE.PutUint16(b[off:], s)
|
||||||
|
icnLE.PutUint16(b[off+2:], s)
|
||||||
|
off += 4
|
||||||
|
}
|
||||||
|
_, _ = a.conn.Write(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
-9
@@ -36,7 +36,13 @@ var icnBE = binary.BigEndian
|
|||||||
// NewIcomNet builds an (unconnected) Icom backend whose transport is the network
|
// NewIcomNet builds an (unconnected) Icom backend whose transport is the network
|
||||||
// stream. host is the rig's IP/hostname; user/pass are the rig's Network User1
|
// stream. host is the rig's IP/hostname; user/pass are the rig's Network User1
|
||||||
// credentials. Reuses the whole IcomSerial controller — only `open` differs.
|
// credentials. Reuses the whole IcomSerial controller — only `open` differs.
|
||||||
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *IcomSerial {
|
//
|
||||||
|
// audioSink (optional) enables the network RX audio stream (UDP 50003): when
|
||||||
|
// non-nil the conninfo asks the rig to stream audio and each received payload is
|
||||||
|
// passed to audioSink (the app decodes it via an audio.Codec and plays it). nil
|
||||||
|
// = CI-V only (the proven default). The audio stream is fully separate from CAT,
|
||||||
|
// so enabling it can't affect freq/mode/DSP control.
|
||||||
|
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial {
|
||||||
if civAddr <= 0 || civAddr > 0xFF {
|
if civAddr <= 0 || civAddr > 0xFF {
|
||||||
civAddr = 0x98 // IC-7610
|
civAddr = 0x98 // IC-7610
|
||||||
}
|
}
|
||||||
@@ -57,7 +63,7 @@ func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *Ic
|
|||||||
b.dialMu.Lock()
|
b.dialMu.Lock()
|
||||||
cancel := b.dialCancel
|
cancel := b.dialCancel
|
||||||
b.dialMu.Unlock()
|
b.dialMu.Unlock()
|
||||||
return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel)
|
return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel, audioSink)
|
||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
@@ -130,6 +136,15 @@ type icomNet struct {
|
|||||||
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
||||||
lastRx atomic.Int64
|
lastRx atomic.Int64
|
||||||
|
|
||||||
|
// dead is set when the rig explicitly tears the session down (control 0x05):
|
||||||
|
// Alive() then returns false immediately so ReadState fails on the next poll and
|
||||||
|
// the manager reconnects cleanly, instead of waiting out the 6 s lastRx timeout.
|
||||||
|
dead atomic.Bool
|
||||||
|
|
||||||
|
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
|
||||||
|
// Torn down alongside the CI-V/control streams in Close.
|
||||||
|
audio *icomAudio
|
||||||
|
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
closeOnce sync.Once
|
closeOnce sync.Once
|
||||||
}
|
}
|
||||||
@@ -168,6 +183,9 @@ func (n *icomNet) markRx() { n.lastRx.Store(time.Now().UnixNano()) }
|
|||||||
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
|
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
|
||||||
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
|
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
|
||||||
func (n *icomNet) Alive() bool {
|
func (n *icomNet) Alive() bool {
|
||||||
|
if n.dead.Load() {
|
||||||
|
return false // rig sent an explicit disconnect — reconnect now, don't wait
|
||||||
|
}
|
||||||
last := n.lastRx.Load()
|
last := n.lastRx.Load()
|
||||||
if last == 0 {
|
if last == 0 {
|
||||||
return true // just connected, nothing received yet — give it a chance
|
return true // just connected, nothing received yet — give it a chance
|
||||||
@@ -232,6 +250,9 @@ var icnTrace = false
|
|||||||
func (n *icomNet) Close() error {
|
func (n *icomNet) Close() error {
|
||||||
n.closeOnce.Do(func() {
|
n.closeOnce.Do(func() {
|
||||||
close(n.done)
|
close(n.done)
|
||||||
|
if n.audio != nil {
|
||||||
|
n.audio.Close()
|
||||||
|
}
|
||||||
// Tell the rig we're leaving so it frees its SINGLE control session at
|
// Tell the rig we're leaving so it frees its SINGLE control session at
|
||||||
// once. If it never gets a disconnect it holds the session for minutes and
|
// once. If it never gets a disconnect it holds the session for minutes and
|
||||||
// refuses every new login — which is why a lost link (or a hard app exit)
|
// refuses every new login — which is why a lost link (or a hard app exit)
|
||||||
@@ -273,19 +294,34 @@ func (n *icomNet) ctrlPump() {
|
|||||||
switch icnLE.Uint16(buf[4:]) {
|
switch icnLE.Uint16(buf[4:]) {
|
||||||
case 0x07: // ping
|
case 0x07: // ping
|
||||||
_, _ = n.ctrl.Write(icnPingReply(buf[:k], n.cID, n.cRemote))
|
_, _ = n.ctrl.Write(icnPingReply(buf[:k], n.cID, n.cRemote))
|
||||||
|
case 0x00: // idle keepalive from the rig — nothing to do
|
||||||
case 0x01: // retransmit request — resend from the CONTROL sent-buffer
|
case 0x01: // retransmit request — resend from the CONTROL sent-buffer
|
||||||
if k >= 8 {
|
if k >= 8 {
|
||||||
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
||||||
}
|
}
|
||||||
case 0x05: // rig-initiated disconnect — it dropped US
|
case 0x05: // rig-initiated disconnect — it dropped US
|
||||||
|
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
|
||||||
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
||||||
|
default:
|
||||||
|
// Anything else on the control stream is (almost always) the rig's
|
||||||
|
// reply to our token renewal. Log it: a 0x40-length token packet
|
||||||
|
// carries a result code, and if the rig is REJECTING renewals this is
|
||||||
|
// where the ~2-3 min disconnect originates. The hex makes the cause
|
||||||
|
// visible in the friend's log without a protocol analyzer.
|
||||||
|
if k >= 0x18 {
|
||||||
|
debugLog.Printf("icom net: control reply len=%d head=% X", k, buf[:0x18])
|
||||||
|
} else {
|
||||||
|
debugLog.Printf("icom net: control reply len=%d head=% X", k, buf[:k])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if time.Since(lastIdle) > 100*time.Millisecond {
|
if time.Since(lastIdle) > 100*time.Millisecond {
|
||||||
_, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote))
|
_, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote))
|
||||||
lastIdle = time.Now()
|
lastIdle = time.Now()
|
||||||
}
|
}
|
||||||
if time.Since(lastToken) > 45*time.Second {
|
// Renew well inside the rig's ~2-min token timeout. 30 s (was 45) leaves room
|
||||||
|
// for one lost renewal + its retransmit before the token would lapse.
|
||||||
|
if time.Since(lastToken) > 30*time.Second {
|
||||||
n.renewToken()
|
n.renewToken()
|
||||||
lastToken = time.Now()
|
lastToken = time.Now()
|
||||||
}
|
}
|
||||||
@@ -340,6 +376,7 @@ func (n *icomNet) civPump() {
|
|||||||
n.resend(icnLE.Uint16(buf[6:]))
|
n.resend(icnLE.Uint16(buf[6:]))
|
||||||
}
|
}
|
||||||
case typ == 0x05: // rig-initiated disconnect — it dropped US
|
case typ == 0x05: // rig-initiated disconnect — it dropped US
|
||||||
|
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
|
||||||
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
|
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
|
||||||
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
|
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
|
||||||
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
|
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
|
||||||
@@ -475,8 +512,9 @@ func (n *icomNet) resend(seq uint16) {
|
|||||||
|
|
||||||
// ------------------------- connect -------------------------
|
// ------------------------- connect -------------------------
|
||||||
|
|
||||||
func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}) (*icomNet, error) {
|
func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}, audioSink func([]byte)) (*icomNet, error) {
|
||||||
debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X)", host, user, compName, rigAddr)
|
wantAudio := audioSink != nil
|
||||||
|
debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X, audio=%v)", host, user, compName, rigAddr, wantAudio)
|
||||||
// ---- control stream (50001): handshake → login → token → conninfo ----
|
// ---- control stream (50001): handshake → login → token → conninfo ----
|
||||||
craddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50001"))
|
craddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50001"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -562,7 +600,11 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan
|
|||||||
rigMAC = make([]byte, 6)
|
rigMAC = make([]byte, 6)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003))
|
var rxEnable byte
|
||||||
|
if wantAudio {
|
||||||
|
rxEnable = 0x01 // ask the rig to stream RX audio on 50003
|
||||||
|
}
|
||||||
|
_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003, rxEnable))
|
||||||
cTracked++
|
cTracked++
|
||||||
cInner++
|
cInner++
|
||||||
drainEnd := time.Now().Add(500 * time.Millisecond)
|
drainEnd := time.Now().Add(500 * time.Millisecond)
|
||||||
@@ -634,6 +676,18 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan
|
|||||||
|
|
||||||
go n.ctrlPump()
|
go n.ctrlPump()
|
||||||
go n.civPump()
|
go n.civPump()
|
||||||
|
|
||||||
|
// Optional RX audio stream (50003). The rig was told (conninfo rxEnable=1) to
|
||||||
|
// stream audio; open the socket + handshake now. A failure here is NON-fatal:
|
||||||
|
// CAT works without audio, so we log and continue rather than tear down a
|
||||||
|
// perfectly good control/CI-V session.
|
||||||
|
if wantAudio {
|
||||||
|
if a, err := dialIcomAudio(host, audioSink, cancel); err != nil {
|
||||||
|
debugLog.Printf("icom net: audio stream FAILED (CAT unaffected): %v", err)
|
||||||
|
} else {
|
||||||
|
n.audio = a
|
||||||
|
}
|
||||||
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,7 +826,7 @@ func icnTokenRenew(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) [
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16) []byte {
|
func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16, rxEnable byte) []byte {
|
||||||
b := make([]byte, 0x90)
|
b := make([]byte, 0x90)
|
||||||
icnLE.PutUint32(b[0:], 0x90)
|
icnLE.PutUint32(b[0:], 0x90)
|
||||||
icnLE.PutUint16(b[6:], seq)
|
icnLE.PutUint16(b[6:], seq)
|
||||||
@@ -788,8 +842,8 @@ func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, use
|
|||||||
copy(b[0x2a:0x30], rigMAC)
|
copy(b[0x2a:0x30], rigMAC)
|
||||||
copy(b[0x40:0x60], []byte("IC-7610"))
|
copy(b[0x40:0x60], []byte("IC-7610"))
|
||||||
copy(b[0x60:0x70], icnPasscode(user))
|
copy(b[0x60:0x70], icnPasscode(user))
|
||||||
b[0x70] = 0x00 // rxenable (audio off — CI-V only)
|
b[0x70] = rxEnable // rxenable: 1 opens the 50003 RX audio stream, 0 = CI-V only
|
||||||
b[0x71] = 0x00 // txenable
|
b[0x71] = 0x00 // txenable (Phase 5)
|
||||||
b[0x72] = 0x10 // rxcodec
|
b[0x72] = 0x10 // rxcodec
|
||||||
b[0x73] = 0x04 // txcodec
|
b[0x73] = 0x04 // txcodec
|
||||||
icnBE.PutUint32(b[0x74:], 16000)
|
icnBE.PutUint32(b[0x74:], 16000)
|
||||||
|
|||||||
@@ -210,17 +210,25 @@ func (b *IcomSerial) Connect() error {
|
|||||||
go b.netScopeFeeder(sc.ScopeChan(), b.readerDone)
|
go b.netScopeFeeder(sc.ScopeChan(), b.readerDone)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort model identification: ask the rig for its own CI-V address.
|
// Best-effort model identification: ask the rig for its own CI-V address. The
|
||||||
|
// 0x19 ID read returns the rig's FACTORY default address (e.g. 0x94 for an
|
||||||
|
// IC-7300) regardless of the address it's currently OPERATING on — so it's the
|
||||||
|
// reliable model signal even when the user runs the rig at a non-default CI-V
|
||||||
|
// address (a common trick to make CAT "just work" at 0x98).
|
||||||
|
idAddr := b.rigAddr // fallback: the configured address if the ID read fails
|
||||||
if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil {
|
if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil {
|
||||||
if f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
if f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
||||||
return d.Cmd == civ.CmdReadID && len(d.Data) >= 2 && d.Data[0] == 0x00
|
return d.Cmd == civ.CmdReadID && len(d.Data) >= 2 && d.Data[0] == 0x00
|
||||||
}); err == nil {
|
}); err == nil {
|
||||||
b.model = civ.ModelName(f.Data[1])
|
b.model = civ.ModelName(f.Data[1])
|
||||||
|
idAddr = f.Data[1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub
|
// Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub
|
||||||
// selector byte; single-scope rigs (IC-7300…) do not.
|
// selector byte; single-scope rigs (IC-7300…) do not. Decide from the
|
||||||
b.dualScope = b.rigAddr == 0x98 || b.rigAddr == 0xA2
|
// IDENTIFIED model, NOT the configured address: an IC-7300 run at 0x98 must
|
||||||
|
// still parse single-scope frames (this was the "scope blank on the 7300" bug).
|
||||||
|
b.dualScope = idAddr == 0x98 || idAddr == 0xA2
|
||||||
// Defer the DSP snapshot until the rig actually answers CI-V. Over the network
|
// Defer the DSP snapshot until the rig actually answers CI-V. Over the network
|
||||||
// the rig may still be booting (or off) at Connect, so an immediate readDSP
|
// the rig may still be booting (or off) at Connect, so an immediate readDSP
|
||||||
// would time out and leave every control at 0 / off with no retry. ReadState
|
// would time out and leave every control at 0 / off with no retry. ReadState
|
||||||
@@ -275,6 +283,7 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
|||||||
b.dspMu.Unlock()
|
b.dspMu.Unlock()
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).")
|
||||||
return RigState{}, err // control link dead → let the Manager reconnect
|
return RigState{}, err // control link dead → let the Manager reconnect
|
||||||
}
|
}
|
||||||
// USB (no liveness signal): the rig briefly stops answering CI-V while it
|
// USB (no liveness signal): the rig briefly stops answering CI-V while it
|
||||||
@@ -590,7 +599,7 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if rawN < 4 {
|
if rawN < 24 {
|
||||||
rawN++
|
rawN++
|
||||||
applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data)
|
applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data)
|
||||||
}
|
}
|
||||||
@@ -696,6 +705,13 @@ func (b *IcomSerial) assembleSweep(regions map[byte][]byte, total byte) {
|
|||||||
// and 0x27 0x11 turns the waveform data OUTPUT over CI-V on. While on, the reader
|
// and 0x27 0x11 turns the waveform data OUTPUT over CI-V on. While on, the reader
|
||||||
// routes every 0x27 frame to scopeLoop.
|
// routes every 0x27 frame to scopeLoop.
|
||||||
func (b *IcomSerial) SetScope(on bool) error {
|
func (b *IcomSerial) SetScope(on bool) error {
|
||||||
|
if on {
|
||||||
|
// Context for the scope-diagnostic log: which rig + whether we expect the
|
||||||
|
// dual-scope (main/sub) frame layout. If the IC-7300 (single scope) streams
|
||||||
|
// but nothing shows, compare its `icom scope raw` frames against this.
|
||||||
|
applog.Printf("icom scope: enable on rig=%q addr=0x%02X dualScope=%v (expect %s frame layout)",
|
||||||
|
b.model, b.rigAddr, b.dualScope, map[bool]string{true: "27 00 [MS] [seq] [total] …", false: "27 00 [seq] [total] …"}[b.dualScope])
|
||||||
|
}
|
||||||
// Some firmwares don't ack 0x27 sets; a timeout here isn't fatal, so log and
|
// Some firmwares don't ack 0x27 sets; a timeout here isn't fatal, so log and
|
||||||
// continue rather than abort the second command.
|
// continue rather than abort the second command.
|
||||||
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil {
|
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil {
|
||||||
|
|||||||
+25
-10
@@ -32,6 +32,7 @@ type OmniRig struct {
|
|||||||
omnirig *ole.IDispatch
|
omnirig *ole.IDispatch
|
||||||
rig *ole.IDispatch
|
rig *ole.IDispatch
|
||||||
lastSig string // last logged Split/VFO signature — only log on change
|
lastSig string // last logged Split/VFO signature — only log on change
|
||||||
|
rigType string // OmniRig's RigType string (the .ini title), e.g. "IC-7610"
|
||||||
|
|
||||||
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
|
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
|
||||||
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
|
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
|
||||||
@@ -80,7 +81,8 @@ func (o *OmniRig) Connect() error {
|
|||||||
o.rig = rigVar.ToIDispatch()
|
o.rig = rigVar.ToIDispatch()
|
||||||
|
|
||||||
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
||||||
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, rt.ToString())
|
o.rigType = rt.ToString()
|
||||||
|
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, o.rigType)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -194,17 +196,20 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
|||||||
s.FreqHz, s.RxFreqHz = freqB, freqA
|
s.FreqHz, s.RxFreqHz = freqB, freqA
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Simplex: the operating frequency is OmniRig's generic Freq (the active
|
// Simplex: read VFO A first, fall back to the generic Freq — exactly like
|
||||||
// VFO), like Log4OM. Fall back to the per-VFO value only if Freq is 0.
|
// DXHunter/WSJT-X. PM_FREQA rigs (Yaesu, Kenwood) populate FreqA; some
|
||||||
|
// Icoms (IC-9100 etc.) only populate the generic Freq. On the IC-7610
|
||||||
|
// OmniRig's generic Freq reports VFO B (its Main/Sub model confuses the
|
||||||
|
// stock ini), so keying off FreqA gives the operator the VFO they expect.
|
||||||
s.Split = false
|
s.Split = false
|
||||||
s.RxFreqHz = 0
|
s.RxFreqHz = 0
|
||||||
s.FreqHz = freqMain
|
switch {
|
||||||
if s.FreqHz == 0 {
|
case freqA != 0:
|
||||||
if s.Vfo == "B" || s.Vfo == "BB" {
|
s.FreqHz = freqA
|
||||||
s.FreqHz = freqB
|
case freqMain != 0:
|
||||||
} else {
|
s.FreqHz = freqMain
|
||||||
s.FreqHz = freqA
|
default:
|
||||||
}
|
s.FreqHz = freqB
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
@@ -414,6 +419,16 @@ func (o *OmniRig) SetPTT(on bool) error {
|
|||||||
debugLog.Printf("OmniRig.SetPTT error: %v", err)
|
debugLog.Printf("OmniRig.SetPTT error: %v", err)
|
||||||
return fmt.Errorf("set Tx=%s: %w", name, err)
|
return fmt.Errorf("set Tx=%s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
// Read the Tx param straight back. OmniRig is async — this may still show the
|
||||||
|
// previous value for a poll cycle — but if a key/unkey NEVER changes it, the
|
||||||
|
// write was coalesced or the rig isn't honouring PM_TX/PM_RX (wrong .ini).
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "Tx"); err == nil {
|
||||||
|
txState := "PM_RX"
|
||||||
|
if v.Val&pmTX != 0 {
|
||||||
|
txState = "PM_TX"
|
||||||
|
}
|
||||||
|
debugLog.Printf("OmniRig.SetPTT: Tx readback = 0x%X (%s)", v.Val, txState)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,23 @@ func (db *DB) Resolve(call string, date time.Time) (Exception, bool) {
|
|||||||
return Exception{}, false
|
return Exception{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasException reports whether the callsign has ANY full-call exception (at any
|
||||||
|
// date). Used to tell a genuinely date-sensitive call (G1T: Scotland only from
|
||||||
|
// 2024) — where cty.dat's date-blind exact-call override is wrong for older
|
||||||
|
// QSOs — from an ordinary call cty.dat handles fine.
|
||||||
|
func (db *DB) HasException(call string) bool {
|
||||||
|
if db == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c := strings.ToUpper(strings.TrimSpace(call))
|
||||||
|
for _, key := range candidates(c) {
|
||||||
|
if len(db.exceptions[key]) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// candidates yields the call and a version with one trailing affix removed.
|
// candidates yields the call and a version with one trailing affix removed.
|
||||||
func candidates(c string) []string {
|
func candidates(c string) []string {
|
||||||
out := []string{c}
|
out := []string{c}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package clublog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func d(s string) time.Time {
|
||||||
|
t, _ := time.Parse("2006-01-02", s)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// G1T is a contest call: Scotland ONLY from 2024-02-21; before that it's England
|
||||||
|
// by prefix. cty.dat's date-blind "=G1T → Scotland" override is wrong for a 2012
|
||||||
|
// QSO — the date-aware ClubLog cascade must give England then, Scotland now.
|
||||||
|
func TestG1TDatedException(t *testing.T) {
|
||||||
|
db := &DB{
|
||||||
|
exceptions: map[string][]Exception{
|
||||||
|
"G1T": {{Call: "G1T", Entity: "Scotland", ADIF: 279, Start: d("2024-02-21")}},
|
||||||
|
},
|
||||||
|
prefixes: map[string][]Exception{
|
||||||
|
"G": {{Entity: "England", ADIF: 223}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2012 QSO: exception doesn't cover it, but the call HAS an exception →
|
||||||
|
// caller should use the prefix (England).
|
||||||
|
if _, ok := db.Resolve("G1T", d("2012-07-29")); ok {
|
||||||
|
t.Fatalf("2012: exception should NOT cover a pre-2024 date")
|
||||||
|
}
|
||||||
|
if !db.HasException("G1T") {
|
||||||
|
t.Fatalf("HasException(G1T) should be true")
|
||||||
|
}
|
||||||
|
pe, pok := db.ResolvePrefix("G1T", d("2012-07-29"))
|
||||||
|
if !pok || pe.Entity != "England" {
|
||||||
|
t.Fatalf("2012 prefix: got %q ok=%v, want England", pe.Entity, pok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2025 QSO: the exception covers it → Scotland.
|
||||||
|
e, ok := db.Resolve("G1T", d("2025-01-01"))
|
||||||
|
if !ok || e.Entity != "Scotland" {
|
||||||
|
t.Fatalf("2025: got %q ok=%v, want Scotland", e.Entity, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ordinary call with no exception → HasException false (caller leaves it
|
||||||
|
// to cty.dat).
|
||||||
|
if db.HasException("G4ABC") {
|
||||||
|
t.Fatalf("HasException(G4ABC) should be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,17 @@ func (m *Manager) Info() (date string, count int) {
|
|||||||
return m.db.Date(), m.db.Count()
|
return m.db.Date(), m.db.Count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NeedsRefresh reports whether the cached country file is missing or older than
|
||||||
|
// maxAge — so the caller re-downloads it to pick up newly-added date-ranged
|
||||||
|
// exceptions (e.g. an event call's fresh SARDINIA period).
|
||||||
|
func (m *Manager) NeedsRefresh(maxAge time.Duration) bool {
|
||||||
|
fi, err := os.Stat(m.Path())
|
||||||
|
if err != nil {
|
||||||
|
return true // missing
|
||||||
|
}
|
||||||
|
return time.Since(fi.ModTime()) > maxAge
|
||||||
|
}
|
||||||
|
|
||||||
// EnsureLoaded loads the cached file into memory if present. Does NOT download.
|
// EnsureLoaded loads the cached file into memory if present. Does NOT download.
|
||||||
func (m *Manager) EnsureLoaded() error {
|
func (m *Manager) EnsureLoaded() error {
|
||||||
if m.Loaded() {
|
if m.Loaded() {
|
||||||
@@ -125,3 +136,19 @@ func (m *Manager) Resolve(call string, date time.Time) (Exception, bool) {
|
|||||||
}
|
}
|
||||||
return db.Resolve(call, date)
|
return db.Resolve(call, date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolvePrefix resolves a call by ClubLog's date-ranged PREFIX table.
|
||||||
|
func (m *Manager) ResolvePrefix(call string, date time.Time) (Exception, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
db := m.db
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return db.ResolvePrefix(call, date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasException reports whether the call has any full-call exception (any date).
|
||||||
|
func (m *Manager) HasException(call string) bool {
|
||||||
|
m.mu.RLock()
|
||||||
|
db := m.db
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return db.HasException(call)
|
||||||
|
}
|
||||||
|
|||||||
+148
-12
@@ -61,6 +61,11 @@ type Spot struct {
|
|||||||
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
||||||
ReceivedAt time.Time `json:"received_at"`
|
ReceivedAt time.Time `json:"received_at"`
|
||||||
Raw string `json:"raw"`
|
Raw string `json:"raw"`
|
||||||
|
// Historical marks a spot recovered from a SH/DX table rather than heard live.
|
||||||
|
// It belongs in the grid, but must NOT fire alerts or reach the panadapter:
|
||||||
|
// replaying 100 past spots would spam both, and a station spotted three hours
|
||||||
|
// ago is not on the air now.
|
||||||
|
Historical bool `json:"historical,omitempty"`
|
||||||
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
|
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
|
||||||
POTAName string `json:"pota_name,omitempty"` // park name
|
POTAName string `json:"pota_name,omitempty"` // park name
|
||||||
}
|
}
|
||||||
@@ -92,6 +97,21 @@ type ServerStatus struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// session is one telnet connection bound to a single server config.
|
// session is one telnet connection bound to a single server config.
|
||||||
|
// Line is one raw line of cluster traffic: everything the server says, plus the
|
||||||
|
// commands we send (echoed, so the console reads as a conversation).
|
||||||
|
//
|
||||||
|
// Spots are PARSED OUT of this stream — but everything else used to be silently
|
||||||
|
// dropped. You could type SH/DX/100 or WHO and never see the answer, which made
|
||||||
|
// the command box look broken. The raw stream is the fix: the console shows what
|
||||||
|
// the server actually said, spot or not.
|
||||||
|
type Line struct {
|
||||||
|
ServerID int64 `json:"server_id"`
|
||||||
|
ServerName string `json:"server_name"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Sent bool `json:"sent"` // true = a command WE sent
|
||||||
|
At string `json:"at"` // HH:MM:SS, local
|
||||||
|
}
|
||||||
|
|
||||||
// Internal — callers use Manager. The onStatus callback is fire-and-
|
// Internal — callers use Manager. The onStatus callback is fire-and-
|
||||||
// forget: it tells the manager something changed; the frontend fetches
|
// forget: it tells the manager something changed; the frontend fetches
|
||||||
// the new aggregate via Status() rather than receiving per-server diffs.
|
// the new aggregate via Status() rather than receiving per-server diffs.
|
||||||
@@ -99,6 +119,7 @@ type session struct {
|
|||||||
cfg ServerConfig
|
cfg ServerConfig
|
||||||
login string
|
login string
|
||||||
onSpot func(Spot)
|
onSpot func(Spot)
|
||||||
|
onLine func(Line)
|
||||||
onStatus func()
|
onStatus func()
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -117,6 +138,7 @@ type Manager struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
sessions map[int64]*session
|
sessions map[int64]*session
|
||||||
onSpot func(Spot)
|
onSpot func(Spot)
|
||||||
|
onLine func(Line)
|
||||||
onStatus func()
|
onStatus func()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,10 +146,11 @@ type Manager struct {
|
|||||||
// spot (with the source server filled in). emitStatusChanged is called
|
// spot (with the source server filled in). emitStatusChanged is called
|
||||||
// whenever ANY server's status changes — the frontend then re-fetches
|
// whenever ANY server's status changes — the frontend then re-fetches
|
||||||
// the aggregate Status() via a Wails binding.
|
// the aggregate Status() via a Wails binding.
|
||||||
func NewManager(emitSpot func(Spot), emitStatusChanged func()) *Manager {
|
func NewManager(emitSpot func(Spot), emitStatusChanged func(), emitLine func(Line)) *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
sessions: make(map[int64]*session),
|
sessions: make(map[int64]*session),
|
||||||
onSpot: emitSpot,
|
onSpot: emitSpot,
|
||||||
|
onLine: emitLine,
|
||||||
onStatus: emitStatusChanged,
|
onStatus: emitStatusChanged,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,6 +178,7 @@ func (m *Manager) StartServer(cfg ServerConfig, login string) {
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
login: login,
|
login: login,
|
||||||
onSpot: m.onSpot,
|
onSpot: m.onSpot,
|
||||||
|
onLine: m.onLine,
|
||||||
onStatus: m.emitStatus,
|
onStatus: m.emitStatus,
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
doneCh: make(chan struct{}),
|
doneCh: make(chan struct{}),
|
||||||
@@ -242,6 +266,12 @@ func (s *session) send(cmd string) error {
|
|||||||
}
|
}
|
||||||
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||||
_, err := conn.Write([]byte(strings.TrimRight(cmd, "\r\n") + "\r\n"))
|
_, err := conn.Write([]byte(strings.TrimRight(cmd, "\r\n") + "\r\n"))
|
||||||
|
if err == nil {
|
||||||
|
// Echo it into the console, so the operator sees WHAT was sent and can pair
|
||||||
|
// it with the reply that follows. A console that only shows one side of the
|
||||||
|
// conversation is barely better than none.
|
||||||
|
s.emitLine(cmd, true)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,8 +383,18 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init commands: fire 1s after login goes through. Each command on
|
// Init commands, once per connection (so they replay after a reconnect).
|
||||||
// its own line; blank lines and "//" comments are skipped.
|
//
|
||||||
|
// Timing: the cluster needs a moment after login before it will take commands,
|
||||||
|
// hence the lead-in wait; then they are PACED, because a DXSpider/AR-Cluster
|
||||||
|
// happily swallows a burst of back-to-back lines and silently ignores half of
|
||||||
|
// them. A fast-but-dropped command is worse than a slow one.
|
||||||
|
//
|
||||||
|
// They go through s.send() — NOT a raw conn.Write, which is what they used to
|
||||||
|
// do. That matters for two reasons: the raw write skipped the write deadline
|
||||||
|
// (a wedged server could hang this goroutine forever), and it skipped the
|
||||||
|
// console echo, so there was no way to SEE whether your init commands had
|
||||||
|
// actually been applied. Now you watch them go out and the reply come back.
|
||||||
initFired := false
|
initFired := false
|
||||||
fireInitCommands := func() {
|
fireInitCommands := func() {
|
||||||
if initFired || strings.TrimSpace(s.cfg.InitCommands) == "" {
|
if initFired || strings.TrimSpace(s.cfg.InitCommands) == "" {
|
||||||
@@ -362,10 +402,9 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
}
|
}
|
||||||
initFired = true
|
initFired = true
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(initCommandLeadIn)
|
||||||
for _, line := range strings.Split(s.cfg.InitCommands, "\n") {
|
for _, line := range strings.Split(s.cfg.InitCommands, "\n") {
|
||||||
line = strings.TrimRight(line, "\r")
|
line = strings.TrimSpace(strings.TrimRight(line, "\r"))
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" || strings.HasPrefix(line, "//") {
|
if line == "" || strings.HasPrefix(line, "//") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -374,8 +413,11 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
_, _ = conn.Write([]byte(line + "\r\n"))
|
if err := s.send(line); err != nil {
|
||||||
time.Sleep(500 * time.Millisecond)
|
s.emitLine("init command failed: "+line+" — "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(initCommandGap)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -444,12 +486,28 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
fireInitCommands()
|
fireInitCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
if spot, ok := parseSpot(line); ok {
|
// EVERY line goes to the console — spot or not. This is the whole point:
|
||||||
|
// SH/DX, WHO, the MOTD and error replies are not spots, and dropping them
|
||||||
|
// (which is what happened before) made the command box look inert.
|
||||||
|
s.emitLine(line, false)
|
||||||
|
|
||||||
|
// Live broadcast first; then the SH/DX table form. Without the second, a
|
||||||
|
// SH/DX reply arrived, matched nothing, and vanished — which is exactly why
|
||||||
|
// "SH/DX/100" looked like it did nothing at all.
|
||||||
|
spot, ok := parseSpot(line)
|
||||||
|
if !ok {
|
||||||
|
spot, ok = parseShowDX(line)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
spot.SourceID = s.cfg.ID
|
spot.SourceID = s.cfg.ID
|
||||||
spot.SourceName = s.cfg.Name
|
spot.SourceName = s.cfg.Name
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.spotsCnt++
|
// Historical spots are a replay of the past, not new traffic — counting
|
||||||
s.status.SpotsCount = s.spotsCnt
|
// them would inflate the server's "spots received" figure.
|
||||||
|
if !spot.Historical {
|
||||||
|
s.spotsCnt++
|
||||||
|
s.status.SpotsCount = s.spotsCnt
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if s.onSpot != nil {
|
if s.onSpot != nil {
|
||||||
s.onSpot(spot)
|
s.onSpot(spot)
|
||||||
@@ -458,13 +516,91 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emitLine hands one line of traffic to the console. Blank lines are dropped —
|
||||||
|
// clusters send plenty and they'd only pad the scrollback.
|
||||||
|
func (s *session) emitLine(text string, sent bool) {
|
||||||
|
if s.onLine == nil || strings.TrimSpace(text) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.onLine(Line{
|
||||||
|
ServerID: s.cfg.ID,
|
||||||
|
ServerName: s.cfg.Name,
|
||||||
|
Text: strings.TrimRight(text, "\r\n"),
|
||||||
|
Sent: sent,
|
||||||
|
At: time.Now().Format("15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- parsing ----------
|
// ---------- parsing ----------
|
||||||
|
|
||||||
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
|
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
|
||||||
|
//
|
||||||
|
// The spotter→freq separator is (?::\s*|\s+): a colon followed by ANY number of
|
||||||
|
// spaces (including ZERO), or one-or-more spaces with no colon. Some RBN skimmer
|
||||||
|
// nodes glue the frequency straight onto the colon — "DX de DL1HWS-3-#:14024.0 …"
|
||||||
|
// — which the old ":?\s+" (colon then a REQUIRED space) rejected, dropping every
|
||||||
|
// spot from those nodes.
|
||||||
var spotRE = regexp.MustCompile(
|
var spotRE = regexp.MustCompile(
|
||||||
`^\s*DX\s+de\s+([A-Z0-9/#\-]+):?\s+(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
|
`^\s*DX\s+de\s+([A-Z0-9/#\-]+)(?::\s*|\s+)(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Pacing for the per-server init commands.
|
||||||
|
//
|
||||||
|
// A cluster is not ready to take commands the instant the login line goes out —
|
||||||
|
// it is still printing its banner — so we wait before the first one. And they are
|
||||||
|
// spaced, because DXSpider / AR-Cluster will happily accept a burst of
|
||||||
|
// back-to-back lines and silently apply only some of them: the connection stays
|
||||||
|
// up, no error is returned, your filters just aren't set. A command that is
|
||||||
|
// dropped is far worse than one that is slow.
|
||||||
|
const (
|
||||||
|
initCommandLeadIn = 1500 * time.Millisecond
|
||||||
|
initCommandGap = 700 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// showDXRE matches the reply to SH/DX — which is a TABLE, not the "DX de …"
|
||||||
|
// broadcast format, and therefore matched nothing at all:
|
||||||
|
//
|
||||||
|
// 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>
|
||||||
|
// freq dxcall date time comment <spotter>
|
||||||
|
//
|
||||||
|
// This is why "SH/DX/100 does nothing": the 100 lines arrive, fail the broadcast
|
||||||
|
// regex, and get dropped. The decimal point in the frequency is required — it is
|
||||||
|
// what keeps ordinary prose out of the spot grid.
|
||||||
|
var showDXRE = regexp.MustCompile(
|
||||||
|
`^\s*(\d{3,7}\.\d+)\s+([A-Z0-9]{1,3}[0-9][A-Z0-9/]*)\s+(\d{1,2}-[A-Za-z]{3}-\d{4})\s+(\d{4})Z?\s*(.*?)\s*(?:<\s*([A-Z0-9/#\-]+)\s*>)?\s*$`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseShowDX turns one line of a SH/DX table into a Spot. The result is flagged
|
||||||
|
// Historical: these are PAST spots, so they belong in the grid but must not fire
|
||||||
|
// alerts or land on the panadapter — replaying 100 of them would spam both, and a
|
||||||
|
// station spotted three hours ago is not on the air now.
|
||||||
|
func parseShowDX(line string) (Spot, bool) {
|
||||||
|
if strings.Contains(strings.ToUpper(line), "DX DE") {
|
||||||
|
return Spot{}, false // that's the broadcast form; spotRE owns it
|
||||||
|
}
|
||||||
|
m := showDXRE.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
return Spot{}, false
|
||||||
|
}
|
||||||
|
khz, err := strconv.ParseFloat(m[1], 64)
|
||||||
|
if err != nil || khz <= 0 {
|
||||||
|
return Spot{}, false
|
||||||
|
}
|
||||||
|
hz := int64(khz * 1000)
|
||||||
|
return Spot{
|
||||||
|
Spotter: strings.ToUpper(m[6]),
|
||||||
|
DXCall: strings.ToUpper(m[2]),
|
||||||
|
FreqKHz: khz,
|
||||||
|
FreqHz: hz,
|
||||||
|
Band: bandFromHz(hz),
|
||||||
|
Comment: strings.TrimSpace(m[5]),
|
||||||
|
TimeUTC: m[4] + "Z",
|
||||||
|
ReceivedAt: time.Now(),
|
||||||
|
Raw: line,
|
||||||
|
Historical: true,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
func parseSpot(line string) (Spot, bool) {
|
func parseSpot(line string) (Spot, bool) {
|
||||||
m := spotRE.FindStringSubmatch(line)
|
m := spotRE.FindStringSubmatch(line)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
|
|||||||
@@ -63,3 +63,57 @@ func TestParseSpotRejectsNoise(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The reply to SH/DX is a TABLE, not the "DX de …" broadcast — a completely
|
||||||
|
// different shape. Because only the broadcast form was parsed, a SH/DX/100 reply
|
||||||
|
// arrived, matched nothing and was dropped: the command looked like it did
|
||||||
|
// nothing at all. These lines must now land in the grid, flagged Historical.
|
||||||
|
func TestParseShowDX(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
line string
|
||||||
|
call string
|
||||||
|
khz float64
|
||||||
|
spotter string
|
||||||
|
}{
|
||||||
|
{" 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>", "EA8DHH", 14195.0, "F5ABC"},
|
||||||
|
{" 7005.5 RA3XYZ 14-Jul-2026 0912Z <DL1ABC>", "RA3XYZ", 7005.5, "DL1ABC"},
|
||||||
|
{"21025.0 VK9/DL2XYZ 1-Jan-2026 0001Z up 2 <JA1ABC>", "VK9/DL2XYZ", 21025.0, "JA1ABC"},
|
||||||
|
// No spotter brackets — still a spot, just without a DE.
|
||||||
|
{" 50313.0 IK0ABC 3-Jul-2026 1500Z FT8", "IK0ABC", 50313.0, ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, ok := parseShowDX(c.line)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("parseShowDX(%q) failed — the SH/DX reply would be dropped again", c.line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got.DXCall != c.call || got.FreqKHz != c.khz || got.Spotter != c.spotter {
|
||||||
|
t.Errorf("parseShowDX(%q) = call %q / %.1f / de %q, want %q / %.1f / %q",
|
||||||
|
c.line, got.DXCall, got.FreqKHz, got.Spotter, c.call, c.khz, c.spotter)
|
||||||
|
}
|
||||||
|
if !got.Historical {
|
||||||
|
t.Errorf("parseShowDX(%q): must be flagged Historical — otherwise 100 replayed spots fire 100 alerts", c.line)
|
||||||
|
}
|
||||||
|
if got.Band == "" {
|
||||||
|
t.Errorf("parseShowDX(%q): band not derived from %.1f kHz", c.line, c.khz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The SH/DX parser must not swallow ordinary cluster prose — a console full of
|
||||||
|
// chatter turned into fake spots would be worse than no parser at all.
|
||||||
|
func TestParseShowDXRejectsNoise(t *testing.T) {
|
||||||
|
noise := []string{
|
||||||
|
"DX de F5ABC: 14195.0 EA8DHH CQ DX 1234Z", // the broadcast form: spotRE owns it
|
||||||
|
"Hello and welcome to the DXSpider cluster",
|
||||||
|
"WWV de VE7CC <18Z> : SFI=110, A=16, K=2",
|
||||||
|
"F4BPO de GB7DXC 12-Jul-2026 2130Z dxspider >",
|
||||||
|
"",
|
||||||
|
"There are 42 users online",
|
||||||
|
}
|
||||||
|
for _, l := range noise {
|
||||||
|
if s, ok := parseShowDX(l); ok {
|
||||||
|
t.Errorf("parseShowDX(%q) wrongly produced a spot: %+v", l, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsConnLost reports whether err means the database was UNREACHABLE (network
|
||||||
|
// down, server gone, dead pooled connection) as opposed to a legitimate
|
||||||
|
// server-side SQL error (constraint violation, bad data, syntax).
|
||||||
|
//
|
||||||
|
// This distinction is the linchpin of the offline safety net: a QSO is parked in
|
||||||
|
// the local ADIF outbox ONLY on a connection loss. If we queued on any error, a
|
||||||
|
// genuine data bug would silently vanish into the file instead of being
|
||||||
|
// reported — the worst possible outcome.
|
||||||
|
//
|
||||||
|
// A *mysql.MySQLError means the SERVER answered and rejected us, so it is never
|
||||||
|
// a connection loss, whatever its code.
|
||||||
|
func IsConnLost(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// The server responded → a real SQL error, not a lost link.
|
||||||
|
var me *mysql.MySQLError
|
||||||
|
if errors.As(err, &me) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, driver.ErrBadConn) || errors.Is(err, sql.ErrConnDone) || errors.Is(err, sql.ErrTxDone) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var ne net.Error
|
||||||
|
if errors.As(err, &ne) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var oe *net.OpError
|
||||||
|
if errors.As(err, &oe) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// The MySQL driver reports a few of these as plain strings.
|
||||||
|
s := strings.ToLower(err.Error())
|
||||||
|
for _, p := range []string{
|
||||||
|
"invalid connection",
|
||||||
|
"bad connection",
|
||||||
|
"connection refused",
|
||||||
|
"connection reset",
|
||||||
|
"broken pipe",
|
||||||
|
"no such host",
|
||||||
|
"i/o timeout",
|
||||||
|
"dial tcp",
|
||||||
|
"unexpected eof",
|
||||||
|
"driver: bad connection",
|
||||||
|
"can't connect",
|
||||||
|
"network is unreachable",
|
||||||
|
"host is unreachable",
|
||||||
|
} {
|
||||||
|
if strings.Contains(s, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
+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 {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Materialised award references per QSO. As soon as a QSO matches an award
|
||||||
|
-- (via the operator's award definitions) or a reference is set by hand, the
|
||||||
|
-- resolved reference(s) are stored here as a compact JSON object keyed by award
|
||||||
|
-- code, e.g. {"DDFM":"74","WAJA":"12"}. The grid's per-award columns then read
|
||||||
|
-- straight from the row like any other column instead of recomputing the whole
|
||||||
|
-- award engine on every page load — much faster, and easy to display anywhere
|
||||||
|
-- (including the shared MySQL logbook). Kept in step by the app: written on log
|
||||||
|
-- / edit / UDP-import and bulk-recomputed when an award definition or reference
|
||||||
|
-- list changes. SQLite ADD COLUMN is metadata-only, fast even on large logbooks.
|
||||||
|
ALTER TABLE qso ADD COLUMN award_refs TEXT;
|
||||||
+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.
|
||||||
|
|||||||
+13
-5
@@ -339,17 +339,25 @@ func normalizeCallsign(s string) string {
|
|||||||
// which can change the DXCC entity (HD5MW/8 → HD8MW → Galápagos, not
|
// which can change the DXCC entity (HD5MW/8 → HD8MW → Galápagos, not
|
||||||
// Ecuador). This is the same class of rule as KG4 and /MM.
|
// Ecuador). This is the same class of rule as KG4 and /MM.
|
||||||
if areaDigit != 0 {
|
if areaDigit != 0 {
|
||||||
main = replaceFirstDigit(main, areaDigit)
|
main = replaceAreaDigit(main, areaDigit)
|
||||||
}
|
}
|
||||||
return main
|
return main
|
||||||
}
|
}
|
||||||
|
|
||||||
// replaceFirstDigit substitutes the first 0-9 digit of a call with d (used to
|
// replaceAreaDigit substitutes the CALL-AREA digit of a call with d (used to
|
||||||
// apply a "/N" call-area change). Returns the call unchanged if it has no digit.
|
// apply a "/N" call-area change). The area digit is the first digit that comes
|
||||||
func replaceFirstDigit(call string, d byte) string {
|
// AFTER a prefix letter — NOT a leading digit that is part of a digit-first
|
||||||
|
// prefix (7X, 3A, 4X, 9A, 2E…). So 7X2ARA/4 → 7X4ARA (still Algeria, area 4),
|
||||||
|
// NOT 4X2ARA (which is Israel — the old first-digit bug). Returns the call
|
||||||
|
// unchanged if it has no such digit.
|
||||||
|
func replaceAreaDigit(call string, d byte) string {
|
||||||
b := []byte(call)
|
b := []byte(call)
|
||||||
|
seenLetter := false
|
||||||
for i := range b {
|
for i := range b {
|
||||||
if b[i] >= '0' && b[i] <= '9' {
|
switch {
|
||||||
|
case b[i] >= 'A' && b[i] <= 'Z':
|
||||||
|
seenLetter = true
|
||||||
|
case b[i] >= '0' && b[i] <= '9' && seenLetter:
|
||||||
b[i] = d
|
b[i] = d
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,6 +219,9 @@ func TestNormalize(t *testing.T) {
|
|||||||
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
|
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
|
||||||
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
|
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
|
||||||
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
|
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
|
||||||
|
"7X2ARA/4": "7X4ARA", // "/4" changes the AREA digit (2→4), NOT the 7 of the 7X prefix (was wrongly 4X2ARA = Israel)
|
||||||
|
"3A2ABC/6": "3A6ABC", // digit-first prefix 3A: area digit is the 2, not the 3
|
||||||
|
"4X1AB/2": "4X2AB", // 4X (Israel) area 1→2, keep the leading 4
|
||||||
"DL/F4BPO": "DL",
|
"DL/F4BPO": "DL",
|
||||||
"MM/KA9P": "MM", // leading MM = Scotland operating prefix
|
"MM/KA9P": "MM", // leading MM = Scotland operating prefix
|
||||||
"MM/LY3X/P": "MM",
|
"MM/LY3X/P": "MM",
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ const clublogRealtimeURL = "https://clublog.org/realtime.php"
|
|||||||
// N QSOs is one HTTP request instead of N realtime.php calls.
|
// N QSOs is one HTTP request instead of N realtime.php calls.
|
||||||
const clublogBatchURL = "https://clublog.org/putlogs.php"
|
const clublogBatchURL = "https://clublog.org/putlogs.php"
|
||||||
|
|
||||||
|
// clublogUserAgent identifies OpsLog to Club Log. Go's default
|
||||||
|
// "Go-http-client/1.1" User-Agent is blocked by Club Log's web front end (nginx
|
||||||
|
// returns 403 Forbidden before the request reaches the app), so every request
|
||||||
|
// must send a real, app-identifying User-Agent.
|
||||||
|
const clublogUserAgent = "OpsLog/1.0 (+https://github.com/GregTroar/OpsLog)"
|
||||||
|
|
||||||
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
|
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
|
||||||
// requires an api parameter that identifies the client software (not the
|
// requires an api parameter that identifies the client software (not the
|
||||||
// user) — the same way Log4OM embeds its own key — so we ship it baked in
|
// user) — the same way Log4OM embeds its own key — so we ship it baked in
|
||||||
@@ -122,6 +128,7 @@ func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConf
|
|||||||
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.Header.Set("User-Agent", clublogUserAgent)
|
||||||
if client == nil {
|
if client == nil {
|
||||||
client = &http.Client{Timeout: 120 * time.Second}
|
client = &http.Client{Timeout: 120 * time.Second}
|
||||||
}
|
}
|
||||||
@@ -135,10 +142,63 @@ func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConf
|
|||||||
if resp.StatusCode == http.StatusOK {
|
if resp.StatusCode == http.StatusOK {
|
||||||
return UploadResult{OK: true, Message: msg}, nil
|
return UploadResult{OK: true, Message: msg}, nil
|
||||||
}
|
}
|
||||||
if msg == "" {
|
diag := clublogFailureDiag(resp, msg)
|
||||||
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
return UploadResult{OK: false, Message: diag}, fmt.Errorf("clublog: batch upload failed: %s", diag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clublogFailureDiag turns a non-200 response into a readable one-liner that
|
||||||
|
// names WHO blocked the request — Club Log's app vs. an intermediary (Cloudflare,
|
||||||
|
// Sucuri, a corporate proxy, an antivirus TLS shim). A bare "403 Forbidden nginx"
|
||||||
|
// page means the request never reached the app; the fingerprint headers below
|
||||||
|
// tell the operator whether it's Club Log's own WAF or something on their side.
|
||||||
|
func clublogFailureDiag(resp *http.Response, body string) string {
|
||||||
|
server := strings.TrimSpace(resp.Header.Get("Server"))
|
||||||
|
// Notable intermediary fingerprints — presence points at the culprit.
|
||||||
|
fp := []string{}
|
||||||
|
for _, h := range []string{"CF-RAY", "CF-Mitigated", "X-Sucuri-ID", "X-Sucuri-Block", "X-Squid-Error", "Via", "X-Cache", "Retry-After"} {
|
||||||
|
if v := strings.TrimSpace(resp.Header.Get(h)); v != "" {
|
||||||
|
fp = append(fp, h+"="+v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: batch upload failed: %s", msg)
|
// Collapse the HTML error page to something short.
|
||||||
|
summary := stripHTMLBrief(body)
|
||||||
|
if summary == "" {
|
||||||
|
summary = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
out := fmt.Sprintf("HTTP %d — %s", resp.StatusCode, summary)
|
||||||
|
if server != "" {
|
||||||
|
out += " [server=" + server + "]"
|
||||||
|
}
|
||||||
|
if len(fp) > 0 {
|
||||||
|
out += " [" + strings.Join(fp, " ") + "]"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripHTMLBrief removes tags and collapses whitespace, returning the first ~160
|
||||||
|
// chars of visible text — enough to read "403 Forbidden" without the markup.
|
||||||
|
func stripHTMLBrief(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
depth := 0
|
||||||
|
for _, r := range s {
|
||||||
|
switch r {
|
||||||
|
case '<':
|
||||||
|
depth++
|
||||||
|
case '>':
|
||||||
|
if depth > 0 {
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if depth == 0 {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.Join(strings.Fields(b.String()), " ")
|
||||||
|
if len(out) > 160 {
|
||||||
|
out = out[:160] + "…"
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestClublog validates the configured credentials by attempting a no-op
|
// TestClublog validates the configured credentials by attempting a no-op
|
||||||
@@ -164,6 +224,7 @@ func clublogPost(ctx context.Context, client *http.Client, endpoint string, form
|
|||||||
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", clublogUserAgent)
|
||||||
if client == nil {
|
if client == nil {
|
||||||
client = &http.Client{Timeout: 20 * time.Second}
|
client = &http.Client{Timeout: 20 * time.Second}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,155 @@ func eqslPost(ctx context.Context, client *http.Client, user, pswd, adif string)
|
|||||||
return msg, nil
|
return msg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eQSL Inbox download endpoint + base host. DownloadInBox.cfm builds an ADIF of
|
||||||
|
// the account's *received* eQSLs (confirmations) and returns an HTML page that
|
||||||
|
// links to the generated .adi file; we then fetch that file (two-step API).
|
||||||
|
const (
|
||||||
|
eqslDownloadInboxURL = "https://www.eQSL.cc/qslcard/DownloadInBox.cfm"
|
||||||
|
eqslBaseURL = "https://www.eQSL.cc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eqslAdiHrefRe pulls the generated .adi link out of the DownloadInBox reply.
|
||||||
|
// eQSL serves old HTML 4.01 with UPPERCASE tags and often UNQUOTED attributes,
|
||||||
|
// e.g. `<A HREF=/downloadedFiles/xxxx.adi>` or `<a href="…/xxxx.adi">`, and the
|
||||||
|
// path may be root-relative or a bare filename. Match case-insensitively with
|
||||||
|
// optional quoting; resolve to an absolute URL afterwards.
|
||||||
|
var eqslAdiHrefRe = regexp.MustCompile(`(?i)href\s*=\s*["']?([^"'>\s]+\.adi)`)
|
||||||
|
|
||||||
|
// DownloadEQSLConfirmations fetches the account's Inbox (received eQSLs =
|
||||||
|
// confirmations) as ADIF text. since is "YYYY-MM-DD" (only QSLs received since
|
||||||
|
// then, incremental "last download"); qthNick comes from the config when the
|
||||||
|
// account has several QTH profiles.
|
||||||
|
func DownloadEQSLConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) {
|
||||||
|
user := strings.ToUpper(strings.TrimSpace(cfg.Username))
|
||||||
|
if user == "" || strings.TrimSpace(cfg.Password) == "" {
|
||||||
|
return "", fmt.Errorf("eqsl: username/password not set")
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 120 * time.Second}
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("UserName", user)
|
||||||
|
q.Set("Password", cfg.Password)
|
||||||
|
if nick := strings.TrimSpace(cfg.QTHNickname); nick != "" {
|
||||||
|
q.Set("QTHNickname", nick)
|
||||||
|
}
|
||||||
|
if s := eqslRcvdSince(since); s != "" {
|
||||||
|
q.Set("RcvdSince", s) // eQSL wants YYYYMMDDHHMM
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: ask eQSL to build the inbox ADIF; the reply is HTML linking to it.
|
||||||
|
page, err := eqslGet(ctx, client, eqslDownloadInboxURL+"?"+q.Encode())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if reason := authErrEQSL(page); reason != "" {
|
||||||
|
return "", fmt.Errorf("eqsl: %s", reason)
|
||||||
|
}
|
||||||
|
m := eqslAdiHrefRe.FindStringSubmatch(page)
|
||||||
|
if m == nil {
|
||||||
|
// eQSL reports problems inline ("Error: …", "You have no … confirmations").
|
||||||
|
return "", fmt.Errorf("eqsl: no download link in response: %s", eqslNoLinkReason(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the link to an absolute URL. eQSL is a Windows/ColdFusion server, so
|
||||||
|
// the path may use BACKSLASHES and a leading `..` (e.g. `..\downloadedFiles\
|
||||||
|
// xxx.adi`); normalise those. It may also be absolute or a bare filename.
|
||||||
|
link := strings.TrimSpace(m[1])
|
||||||
|
link = strings.ReplaceAll(link, `\`, "/")
|
||||||
|
link = strings.TrimPrefix(link, "..") // "../downloadedFiles/…" → "/downloadedFiles/…"
|
||||||
|
var fileURL string
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(strings.ToLower(link), "http"):
|
||||||
|
fileURL = link
|
||||||
|
case strings.HasPrefix(link, "/"):
|
||||||
|
fileURL = eqslBaseURL + link
|
||||||
|
default:
|
||||||
|
fileURL = eqslBaseURL + "/" + link
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: fetch the generated .adi file.
|
||||||
|
adif, err := eqslGet(ctx, client, fileURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("eqsl: fetch adif: %w", err)
|
||||||
|
}
|
||||||
|
return adif, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// eqslNoLinkReason builds a helpful message when no .adi link was found: eQSL's
|
||||||
|
// own error/notice text if present, else a short tag-stripped snippet of the page
|
||||||
|
// so the real wording (e.g. "You have no Inbox items") is visible in the log.
|
||||||
|
func eqslNoLinkReason(page string) string {
|
||||||
|
low := strings.ToLower(page)
|
||||||
|
for _, marker := range []string{"you have no", "no inbox", "error", "invalid", "not found"} {
|
||||||
|
if i := strings.Index(low, marker); i >= 0 {
|
||||||
|
seg := page[i:]
|
||||||
|
if len(seg) > 160 {
|
||||||
|
seg = seg[:160]
|
||||||
|
}
|
||||||
|
return strings.Join(strings.Fields(stripTags(seg)), " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
txt := strings.Join(strings.Fields(stripTags(page)), " ")
|
||||||
|
if len(txt) > 200 {
|
||||||
|
txt = txt[:200]
|
||||||
|
}
|
||||||
|
if txt == "" {
|
||||||
|
txt = "empty response"
|
||||||
|
}
|
||||||
|
return txt
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripTags removes HTML tags for a readable one-line diagnostic.
|
||||||
|
func stripTags(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
depth := 0
|
||||||
|
for _, r := range s {
|
||||||
|
switch r {
|
||||||
|
case '<':
|
||||||
|
depth++
|
||||||
|
case '>':
|
||||||
|
if depth > 0 {
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if depth == 0 {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// eqslGet does a plain GET and returns the body as text (32 MB cap), erroring on
|
||||||
|
// a non-200 status.
|
||||||
|
func eqslGet(ctx context.Context, client *http.Client, u string) (string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("eqsl: build request: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("eqsl: request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("eqsl: http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// eqslRcvdSince converts the app's "YYYY-MM-DD" into eQSL's RcvdSince format
|
||||||
|
// "YYYYMMDDHHMM" (midnight). Empty in → empty out (full pull).
|
||||||
|
func eqslRcvdSince(since string) string {
|
||||||
|
s := strings.ReplaceAll(strings.TrimSpace(since), "-", "")
|
||||||
|
if len(s) == 8 { // YYYYMMDD → append 0000 (00:00)
|
||||||
|
return s + "0000"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// authErrEQSL returns a reason when the response signals bad credentials, else
|
// authErrEQSL returns a reason when the response signals bad credentials, else
|
||||||
// "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd".
|
// "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd".
|
||||||
func authErrEQSL(body string) string {
|
func authErrEQSL(body string) string {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package extsvc
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -212,6 +214,15 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri
|
|||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
if ee, ok := runErr.(*exec.ExitError); ok {
|
if ee, ok := runErr.(*exec.ExitError); ok {
|
||||||
code = ee.ExitCode()
|
code = ee.ExitCode()
|
||||||
|
} else if errors.Is(runErr, syscall.Errno(740)) || strings.Contains(strings.ToLower(runErr.Error()), "requires elevation") {
|
||||||
|
// ERROR_ELEVATION_REQUIRED (740): tqsl.exe is set to require admin
|
||||||
|
// rights (its "Run as administrator" compatibility flag, or an
|
||||||
|
// AppCompat RUNASADMIN entry), but OpsLog isn't elevated so Windows
|
||||||
|
// refuses to launch it. Actionable message instead of the raw error.
|
||||||
|
return UploadResult{}, fmt.Errorf(
|
||||||
|
"lotw: Windows won't launch tqsl.exe because it's marked \"Run as administrator\". " +
|
||||||
|
"Fix: right-click %q → Properties → Compatibility → UNTICK \"Run this program as an administrator\" (Apply). " +
|
||||||
|
"Or run OpsLog itself as administrator.", tqsl)
|
||||||
} else {
|
} else {
|
||||||
return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr)
|
return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr)
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-18
@@ -89,6 +89,9 @@ type Manager struct {
|
|||||||
rnd *rand.Rand
|
rnd *rand.Rand
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxUploadAttempts bounds retries of a transient upload failure.
|
||||||
|
const maxUploadAttempts = 4
|
||||||
|
|
||||||
func NewManager(deps Deps) *Manager {
|
func NewManager(deps Deps) *Manager {
|
||||||
if deps.Client == nil {
|
if deps.Client == nil {
|
||||||
deps.Client = &http.Client{Timeout: 20 * time.Second}
|
deps.Client = &http.Client{Timeout: 20 * time.Second}
|
||||||
@@ -101,6 +104,24 @@ func NewManager(deps Deps) *Manager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attemptUpload uploads a QSO in its OWN goroutine and, on a TRANSIENT failure
|
||||||
|
// (rate-limit / network), re-arms itself with exponential back-off. Each upload is
|
||||||
|
// independent — never serialised through a shared worker, because a single slow
|
||||||
|
// upload (LoTW signs via TQSL; a service on a 30 s timeout) would otherwise block
|
||||||
|
// every following QSO's upload and strand them all at "R" (the regression that hit
|
||||||
|
// the operator on the newest build while everyone on the old concurrent path was
|
||||||
|
// fine).
|
||||||
|
func (m *Manager) attemptUpload(svc Service, id int64, cfg ServiceConfig, attempt int) {
|
||||||
|
go func() {
|
||||||
|
ok, retryable := m.upload(svc, id, cfg)
|
||||||
|
if !ok && retryable && attempt+1 < maxUploadAttempts {
|
||||||
|
backoff := time.Duration(1<<uint(attempt)) * time.Second // 1s, 2s, 4s…
|
||||||
|
m.logf("extsvc: %s upload of QSO %d will retry (attempt %d) in %s", svc, id, attempt+2, backoff)
|
||||||
|
time.AfterFunc(backoff, func() { m.attemptUpload(svc, id, cfg, attempt+1) })
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) logf(format string, args ...any) {
|
func (m *Manager) logf(format string, args ...any) {
|
||||||
if m.deps.Logf != nil {
|
if m.deps.Logf != nil {
|
||||||
m.deps.Logf(format, args...)
|
m.deps.Logf(format, args...)
|
||||||
@@ -175,15 +196,17 @@ func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
|
|||||||
m.scheduleUpload(svc, id, cfg)
|
m.scheduleUpload(svc, id, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// scheduleUpload either uploads now (immediate) or arms a timer (delayed).
|
// scheduleUpload uploads now (immediate) or after a random fuse (delayed). Each
|
||||||
|
// upload runs in its own goroutine (attemptUpload) — never serialised — so a slow
|
||||||
|
// one never holds up the rest.
|
||||||
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
||||||
if cfg.UploadMode == ModeDelayed {
|
if cfg.UploadMode == ModeDelayed {
|
||||||
d := m.delaySeconds()
|
d := m.delaySeconds()
|
||||||
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
|
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
|
||||||
time.AfterFunc(d, func() { m.upload(svc, id, cfg) })
|
time.AfterFunc(d, func() { m.attemptUpload(svc, id, cfg, 0) })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go m.upload(svc, id, cfg)
|
m.attemptUpload(svc, id, cfg, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// onCloseServices returns the services configured for on-close auto-upload,
|
// onCloseServices returns the services configured for on-close auto-upload,
|
||||||
@@ -243,25 +266,25 @@ func (m *Manager) FlushOnClose() int {
|
|||||||
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
||||||
case ServiceQRZ:
|
case ServiceQRZ:
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if m.upload(svc, id, cfg.QRZ) {
|
if ok, _ := m.upload(svc, id, cfg.QRZ); ok {
|
||||||
uploaded++
|
uploaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ServiceClublog:
|
case ServiceClublog:
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if m.upload(svc, id, cfg.Clublog) {
|
if ok, _ := m.upload(svc, id, cfg.Clublog); ok {
|
||||||
uploaded++
|
uploaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ServiceHRDLog:
|
case ServiceHRDLog:
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if m.upload(svc, id, cfg.HRDLog) {
|
if ok, _ := m.upload(svc, id, cfg.HRDLog); ok {
|
||||||
uploaded++
|
uploaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ServiceEQSL:
|
case ServiceEQSL:
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if m.upload(svc, id, cfg.EQSL) {
|
if ok, _ := m.upload(svc, id, cfg.EQSL); ok {
|
||||||
uploaded++
|
uploaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,12 +335,16 @@ func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
|
|||||||
// upload performs the actual push and returns true on success. It builds a
|
// upload performs the actual push and returns true on success. It builds a
|
||||||
// fresh, lifecycle-independent context so a delayed upload still completes
|
// fresh, lifecycle-independent context so a delayed upload still completes
|
||||||
// even if it fires close to shutdown.
|
// even if it fires close to shutdown.
|
||||||
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
// upload performs one upload. It returns ok=true when the QSO was uploaded (and
|
||||||
|
// marked), and retryable=true when it failed in a way worth retrying later (an
|
||||||
|
// HTTP/service error such as a rate-limit 403) as opposed to a permanent skip
|
||||||
|
// (not eligible, wrong station callsign, no record).
|
||||||
|
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, retryable bool) {
|
||||||
// Skip QSOs that aren't eligible (already sent, or sent status doesn't
|
// Skip QSOs that aren't eligible (already sent, or sent status doesn't
|
||||||
// match the configured Upload flag).
|
// match the configured Upload flag).
|
||||||
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(svc, id) {
|
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(svc, id) {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (not eligible)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (not eligible)", svc, id)
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Station-callsign guard. Each logbook belongs to one callsign:
|
// Station-callsign guard. Each logbook belongs to one callsign:
|
||||||
@@ -345,7 +372,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
if m.deps.NotifyError != nil {
|
if m.deps.NotifyError != nil {
|
||||||
m.deps.NotifyError(svc, id, err)
|
m.deps.NotifyError(svc, id, err)
|
||||||
}
|
}
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +387,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
|
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
|
||||||
case ServiceClublog:
|
case ServiceClublog:
|
||||||
@@ -369,7 +396,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
record, ok := m.deps.BuildADIF(id, "")
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
||||||
case ServiceLoTW:
|
case ServiceLoTW:
|
||||||
@@ -378,7 +405,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
res, err = UploadLoTW(ctx, cfg, "", record)
|
res, err = UploadLoTW(ctx, cfg, "", record)
|
||||||
case ServiceHRDLog:
|
case ServiceHRDLog:
|
||||||
@@ -387,7 +414,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
record, ok := m.deps.BuildADIF(id, "")
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record)
|
res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record)
|
||||||
case ServiceEQSL:
|
case ServiceEQSL:
|
||||||
@@ -396,11 +423,11 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
record, ok := m.deps.BuildADIF(id, "")
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
|
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
|
||||||
default:
|
default:
|
||||||
return false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil || !res.OK {
|
if err != nil || !res.OK {
|
||||||
@@ -411,12 +438,12 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
if m.deps.NotifyError != nil {
|
if m.deps.NotifyError != nil {
|
||||||
m.deps.NotifyError(svc, id, err)
|
m.deps.NotifyError(svc, id, err)
|
||||||
}
|
}
|
||||||
return false
|
return false, true // transient (rate-limit / network) → worth a retry
|
||||||
}
|
}
|
||||||
|
|
||||||
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
|
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
|
||||||
if m.deps.MarkUploaded != nil {
|
if m.deps.MarkUploaded != nil {
|
||||||
m.deps.MarkUploaded(svc, id, res.LogID)
|
m.deps.MarkUploaded(svc, id, res.LogID)
|
||||||
}
|
}
|
||||||
return true
|
return true, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,20 @@ type Event struct {
|
|||||||
|
|
||||||
DXCall string // ServiceWSJT (Status) or ServiceRemoteCall
|
DXCall string // ServiceWSJT (Status) or ServiceRemoteCall
|
||||||
DXGrid string // ServiceWSJT (Status)
|
DXGrid string // ServiceWSJT (Status)
|
||||||
Mode string // ServiceWSJT (Status)
|
Mode string // ServiceWSJT (Status/Decode)
|
||||||
FreqHz int64 // ServiceWSJT (Status)
|
FreqHz int64 // ServiceWSJT (Status)
|
||||||
LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM
|
LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM
|
||||||
|
|
||||||
|
// A WSJT-X Decode (heard station) to render on the panadapter.
|
||||||
|
DecodeCall string // transmitting (DE) callsign
|
||||||
|
DecodeFreqHz int64 // RF frequency (dial + audio offset)
|
||||||
|
DecodeSNR int // reported SNR (dB)
|
||||||
|
DecodeCQ bool // the decode was a CQ
|
||||||
|
|
||||||
|
// ClearCall is set when a WSJT/JTDX/MSHV Status message reports an EMPTY DX
|
||||||
|
// Call after previously reporting one — i.e. the operator cleared the call in
|
||||||
|
// the digital app. OpsLog clears its entry to match.
|
||||||
|
ClearCall bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is a single inbound UDP listener.
|
// Server is a single inbound UDP listener.
|
||||||
@@ -57,6 +68,9 @@ type Server struct {
|
|||||||
done chan struct{}
|
done chan struct{}
|
||||||
stopped bool
|
stopped bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|
||||||
|
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
|
||||||
|
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
|
||||||
}
|
}
|
||||||
|
|
||||||
func newServer(cfg Config, out chan<- Event) *Server {
|
func newServer(cfg Config, out chan<- Event) *Server {
|
||||||
@@ -175,9 +189,29 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
applog.Printf("udp: [%s] WSJT msg type ignored\n", s.cfg.Name)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Status carries the current dial frequency; remember it so Decode audio
|
||||||
|
// offsets can be turned into RF frequencies for the panadapter.
|
||||||
|
if w.FreqHz > 0 && !w.IsDecode {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.lastDialHz = w.FreqHz
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
if w.IsDecode {
|
||||||
|
s.mu.Lock()
|
||||||
|
dial := s.lastDialHz
|
||||||
|
s.mu.Unlock()
|
||||||
|
if dial <= 0 {
|
||||||
|
return // no dial freq yet (Status not seen) → can't place the spot
|
||||||
|
}
|
||||||
|
ev.DecodeCall = w.DecodeCall
|
||||||
|
ev.DecodeFreqHz = dial + w.DeltaFreqHz
|
||||||
|
ev.DecodeSNR = w.SNR
|
||||||
|
ev.DecodeCQ = w.IsCQ
|
||||||
|
ev.Mode = w.Mode
|
||||||
|
break
|
||||||
|
}
|
||||||
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
|
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
|
||||||
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
|
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
|
||||||
ev.DXCall = w.DXCall
|
ev.DXCall = w.DXCall
|
||||||
@@ -185,6 +219,17 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
ev.Mode = w.Mode
|
ev.Mode = w.Mode
|
||||||
ev.FreqHz = w.FreqHz
|
ev.FreqHz = w.FreqHz
|
||||||
ev.LoggedADIF = w.LoggedADIF
|
ev.LoggedADIF = w.LoggedADIF
|
||||||
|
// A Status with an empty DX Call, right after one that had a call, means the
|
||||||
|
// operator cleared it in WSJT-X / JTDX / MSHV. Fire ONE clear (tracked per
|
||||||
|
// server) — an idle app sends empty Status every second, and we must not
|
||||||
|
// re-clear (which would fight a manual entry) on each of those.
|
||||||
|
s.mu.Lock()
|
||||||
|
prev := s.lastDX
|
||||||
|
s.lastDX = w.DXCall
|
||||||
|
s.mu.Unlock()
|
||||||
|
if w.DXCall == "" && prev != "" {
|
||||||
|
ev.ClearCall = true
|
||||||
|
}
|
||||||
case ServiceADIF:
|
case ServiceADIF:
|
||||||
// JTAlert / GridTracker forward a text ADIF record after a QSO is
|
// JTAlert / GridTracker forward a text ADIF record after a QSO is
|
||||||
// logged. Guard against keep-alive / non-ADIF chatter on the socket:
|
// logged. Guard against keep-alive / non-ADIF chatter on the socket:
|
||||||
@@ -240,8 +285,9 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Empty events are useless; skip.
|
// Empty events are useless; skip — EXCEPT a clear signal, which is meant to be
|
||||||
if ev.DXCall == "" && ev.LoggedADIF == "" {
|
// empty (the DX Call was cleared in the digital app).
|
||||||
|
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" && !ev.ClearCall {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user