Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2bd818ac4 | ||
|
|
d71d09cbb6 | ||
|
|
557fb162c3 | ||
|
|
77a2350240 | ||
|
|
2b3d118d84 | ||
|
|
638ffcb326 | ||
|
|
bf4fba484a | ||
|
|
3ea7f44fd1 | ||
|
|
384925f24a | ||
|
|
898323c49f | ||
|
|
48345a22fc | ||
|
|
b83d55cc32 | ||
|
|
76e4288b9e | ||
|
|
246ecf23ba | ||
|
|
1717677056 | ||
|
|
15159f38a5 | ||
|
|
6a28344e93 | ||
|
|
a14ade9277 | ||
|
|
513ab178ec | ||
|
|
e3aeeae616 | ||
|
|
a5cfecbf76 | ||
|
|
7d644d05cc | ||
|
|
75510a1161 | ||
|
|
1a0a349fb3 | ||
|
|
5e09b6a796 | ||
|
|
d6a2e84eed | ||
|
|
7b0ef0ba97 | ||
|
|
aa871a07b7 | ||
|
|
89584f173d | ||
|
|
a71e48f811 | ||
|
|
5c4f101402 | ||
|
|
ede9461010 | ||
|
|
e6ca7a8bdd | ||
|
|
9be3f9147b | ||
|
|
f7f801cdb7 | ||
|
|
43f15f1a2c | ||
|
|
2c5416500f | ||
|
|
fcdc5809e6 | ||
|
|
7254950162 | ||
|
|
1668455ff4 | ||
|
|
c07b746d4b | ||
|
|
d128dabc19 | ||
|
|
2823f3e401 | ||
|
|
1070637c40 | ||
|
|
13de53772b | ||
|
|
6b04072e52 |
+41
-15
@@ -42,7 +42,10 @@ Développé par **F4BPO**.
|
|||||||
district (`/8`, `/W6`), plus les dérogations DXpédition de ClubLog par dates.
|
district (`/8`, `/W6`), plus les dérogations DXpédition de ClubLog par dates.
|
||||||
- **QSO récents**, matrice **déjà contactés** (par créneau bande/mode),
|
- **QSO récents**, matrice **déjà contactés** (par créneau bande/mode),
|
||||||
re-résolution en masse depuis cty/QRZ/ClubLog, envoi en masse vers les services
|
re-résolution en masse depuis cty/QRZ/ClubLog, envoi en masse vers les services
|
||||||
QSL.
|
QSL. Un **compteur de sélection** en direct, une bascule **Tout sélectionner /
|
||||||
|
Tout désélectionner**, et une limite d'affichage qui garde le journal complet
|
||||||
|
rapide **mais qui saute dès qu'un filtre est actif** (toutes les correspondances
|
||||||
|
sont montrées, pas seulement la première page).
|
||||||
- **Constructeur de filtres QSO avancé** (champ / opérateur / valeur, ET / OU,
|
- **Constructeur de filtres QSO avancé** (champ / opérateur / valeur, ET / OU,
|
||||||
préréglages enregistrés) avec **export ADIF** des lignes filtrées ou
|
préréglages enregistrés) avec **export ADIF** des lignes filtrées ou
|
||||||
sélectionnées.
|
sélectionnées.
|
||||||
@@ -67,7 +70,9 @@ Développé par **F4BPO**.
|
|||||||
légendés) et le(s) **lobe(s) du faisceau d'antenne** tracés depuis l'azimut du
|
légendés) et le(s) **lobe(s) du faisceau d'antenne** tracés depuis l'azimut du
|
||||||
rotor.
|
rotor.
|
||||||
- **Compas de rotor** (azimutal équidistant, clic pour tourner) piloté par
|
- **Compas de rotor** (azimutal équidistant, clic pour tourner) piloté par
|
||||||
PstRotator.
|
**PstRotator** (UDP), un **Rotator Genius 4O3A** (TCP natif) ou un **microHAM
|
||||||
|
ARCO** contrôlé nativement — sans PstRotator — via le **réseau** ou l'**USB**
|
||||||
|
avec son protocole Yaesu GS-232A.
|
||||||
- **Support Ultrabeam** (Normal / inversé 180° / bidirectionnel) : la direction
|
- **Support Ultrabeam** (Normal / inversé 180° / bidirectionnel) : la direction
|
||||||
rayonnée est en vert et le **boom mécanique** en gris, à la fois sur le compas
|
rayonnée est en vert et le **boom mécanique** en gris, à la fois sur le compas
|
||||||
et sur la carte, pour toujours savoir où pointe l'antenne.
|
et sur la carte, pour toujours savoir où pointe l'antenne.
|
||||||
@@ -81,7 +86,9 @@ Développé par **F4BPO**.
|
|||||||
Cluster et le volet cluster de la vue principale, avec bascule affichage/masquage.
|
Cluster et le volet cluster de la vue principale, avec bascule affichage/masquage.
|
||||||
- **Statut** par spot (nouveau / nouvelle bande / nouveau créneau / contacté),
|
- **Statut** par spot (nouveau / nouvelle bande / nouveau créneau / contacté),
|
||||||
clic pour accorder la radio, et une **bandmap** multi-bandes (bandes façon
|
clic pour accorder la radio, et une **bandmap** multi-bandes (bandes façon
|
||||||
panadapter).
|
panadapter). En option, tous les **modes numériques comptent comme un seul**
|
||||||
|
(style DXCC) pour le coloriage nouveau/nouveau-créneau et les badges de la
|
||||||
|
matrice des déjà-contactés (Réglages → Général).
|
||||||
- Les spots **POTA** sont étiquetés avec leur référence de parc (via
|
- Les spots **POTA** sont étiquetés avec leur référence de parc (via
|
||||||
`api.pota.app`).
|
`api.pota.app`).
|
||||||
- **Alertes de spots** (façon Log4OM) : règles sur indicatif / pays / bande /
|
- **Alertes de spots** (façon Log4OM) : règles sur indicatif / pays / bande /
|
||||||
@@ -97,7 +104,8 @@ connexion rapide non bloquante — une radio éteinte ne fige jamais l'applicati
|
|||||||
supportée par OmniRig.
|
supportée par OmniRig.
|
||||||
- **FlexRadio (SmartSDR)** via l'API TCP de la radio — fréquence / mode / split
|
- **FlexRadio (SmartSDR)** via l'API TCP de la radio — fréquence / mode / split
|
||||||
de la slice en temps réel, découverte UDP, et **spots panadapter** (les spots
|
de la slice en temps réel, découverte UDP, et **spots panadapter** (les spots
|
||||||
du cluster poussés sur l'écran Flex, clic → remplir l'indicatif).
|
du cluster poussés sur l'écran Flex ; un clic remplit l'indicatif et **accorde
|
||||||
|
la radio sur la bonne fréquence ET le bon mode**).
|
||||||
- **Icom CI-V** — natif, via le port **USB** de la radio *ou* par internet via le
|
- **Icom CI-V** — natif, via le port **USB** de la radio *ou* par internet via le
|
||||||
**serveur LAN intégré** de la radio (voir *Icom à distance* ci-dessous). Ni
|
**serveur LAN intégré** de la radio (voir *Icom à distance* ci-dessous). Ni
|
||||||
RS-BA1 ni Remote Utility nécessaires.
|
RS-BA1 ni Remote Utility nécessaires.
|
||||||
@@ -114,12 +122,17 @@ Affiché uniquement quand le backend CAT est une FlexRadio :
|
|||||||
|
|
||||||
- **Émission :** puissance RF, puissance d'accord, TUNE, MOX, processeur de
|
- **Émission :** puissance RF, puissance d'accord, TUNE, MOX, processeur de
|
||||||
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) :** sélecteurs d'**antenne** RX/TX et une bascule
|
||||||
|
**DAX** (audio d'émission via DAX, pour WSJT-X & co), mode/seuil AGC, niveau
|
||||||
|
audio, NB / WNB / NR / ANF, et — sur les radios **SmartSDR v4** (séries 8000 /
|
||||||
|
Aurora) — les filtres DSP supplémentaires **NRL, NRS, NRF** (avec niveau) plus
|
||||||
|
**RNN** (réduction de bruit par IA) et **ANFT** (notch automatique FFT),
|
||||||
|
affichés automatiquement si la radio les supporte. **RIT / XIT** avec accord
|
||||||
|
molette / ±.
|
||||||
- **Coupleur d'antenne (ATU) :** accord / bypass / mémoires.
|
- **Coupleur d'antenne (ATU) :** accord / bypass / mémoires.
|
||||||
- **Amplificateur :** la carte de commande suit l'ampli configuré —
|
- **Amplificateur :** la carte de commande suit l'ampli configuré, avec une liste
|
||||||
**PowerGenius XL** (operate/standby, mode ventilateur, défaut) ou
|
déroulante pour le choisir quand **plusieurs amplificateurs** sont configurés
|
||||||
**SPE Expert** (operate/standby, Marche/Arrêt, niveau Low/Mid/High, barre de
|
(p. ex. deux SPE en parallèle). Voir *Amplis & commutateurs* ci-dessous.
|
||||||
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.
|
||||||
|
|
||||||
@@ -178,14 +191,23 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
|
|||||||
|
|
||||||
## Amplis & commutateurs
|
## Amplis & commutateurs
|
||||||
|
|
||||||
- **Amplificateurs** (Réglages → Amplificateur — la carte de commande apparaît
|
- **Amplificateurs** — configurez **un ou plusieurs** amplis (Réglages →
|
||||||
sur l'onglet FlexRadio, ou seule quand ni Flex ni Icom n'est actif) :
|
Amplificateur est une liste ; p. ex. deux SPE en parallèle pour plus de
|
||||||
|
puissance). La carte de chaque ampli apparaît sur l'onglet FlexRadio et dans
|
||||||
|
**Station Control**, avec une liste déroulante pour choisir lequel afficher ; la
|
||||||
|
barre de statut du bas porte **une pastille cliquable par ampli** (vert =
|
||||||
|
OPERATE, orange = STANDBY, rouge = hors ligne). Supportés :
|
||||||
- **PowerGenius XL** (4O3A) en TCP direct — operate/standby, sélecteur de mode
|
- **PowerGenius XL** (4O3A) en TCP direct — operate/standby, sélecteur de mode
|
||||||
ventilateur et affichage des défauts.
|
ventilateur et affichage des défauts.
|
||||||
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) via **USB** (COM virtuel) ou le
|
- **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
|
**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,
|
sortie Low / Mid / High, une barre de puissance et le statut en direct (bande,
|
||||||
ROS, courant PA, température, avertissements/alarmes).
|
ROS, courant PA, température, avertissements/alarmes).
|
||||||
|
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) via **USB** ou le **réseau**
|
||||||
|
(pont RS232-Ethernet) — operate/standby/arrêt et télémétrie en direct
|
||||||
|
(puissance directe & réfléchie, ROS, température PA, bande, ventilateur,
|
||||||
|
défauts). La mise en marche fonctionne via un câble série reliant les lignes
|
||||||
|
DTR/RTS.
|
||||||
- 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
|
- Panneau **Station Control** (ancrable, widgets réordonnables par glisser) : le
|
||||||
@@ -235,10 +257,11 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
|
|||||||
## 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.
|
||||||
**TM74TFR**) : Réglages → Général → *Publier le statut opérateur en direct*.
|
**TM74TFR**), la publication est **automatique** — aucun réglage à activer.
|
||||||
Chaque instance OpsLog envoie un battement de cœur de son activité (indicatif de
|
Chaque instance OpsLog envoie un battement de cœur de son activité (indicatif de
|
||||||
l'opérateur, bande, fréquence, mode) dans une table `live_status` toutes les
|
l'opérateur, bande, fréquence, mode) dans une table `live_status` toutes les
|
||||||
~15 s. Un petit rendu PHP
|
~15 s, et repasse *hors antenne* automatiquement 5 min après le dernier QSO
|
||||||
|
logué. Un petit rendu PHP
|
||||||
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) sur votre
|
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) sur votre
|
||||||
propre serveur web lit cette table et produit une page/image en direct que vous
|
propre serveur web lit cette table et produit une page/image en direct que vous
|
||||||
pouvez intégrer sur la bio **QRZ.com** de la station
|
pouvez intégrer sur la bio **QRZ.com** de la station
|
||||||
@@ -248,8 +271,11 @@ ce n'est pas un serveur web.
|
|||||||
## Net control
|
## Net control
|
||||||
|
|
||||||
- **Log de net dirigé** (Outils → Net) : un roster global (`nets.json`) plus une
|
- **Log de net dirigé** (Outils → Net) : un roster global (`nets.json`) plus une
|
||||||
session active en mémoire — pointez les stations présentes, puis loguez tout le
|
session active en mémoire — pointez les stations présentes, puis loguez-les une
|
||||||
net d'un coup en utilisant la fréquence CAT.
|
par une ou tout le net d'un coup (**Logger tout le monde**) en utilisant la
|
||||||
|
fréquence CAT. **Glisser-déposer** entre les deux listes (roster → on air démarre
|
||||||
|
un QSO, on air → roster l'enregistre), et après chaque log la station on air
|
||||||
|
suivante est sélectionnée automatiquement pour enchaîner les contacts.
|
||||||
|
|
||||||
## Apparence & langue
|
## Apparence & langue
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ Developed by **F4BPO**.
|
|||||||
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
|
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
|
||||||
ClubLog DXpedition date overrides.
|
ClubLog DXpedition date overrides.
|
||||||
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
||||||
from cty/QRZ/ClubLog, bulk send to QSL services.
|
from cty/QRZ/ClubLog, bulk send to QSL services. A live **selection count**, a
|
||||||
|
**Select all / Unselect all** toggle, and a row limit that keeps the full log
|
||||||
|
fast **but is lifted while a filter is active** (every match is shown, not just
|
||||||
|
the first page).
|
||||||
- **Advanced QSO filter builder** (field / operator / value, AND / OR, saved
|
- **Advanced QSO filter builder** (field / operator / value, AND / OR, saved
|
||||||
presets) with filtered- and selected-row **ADIF export**.
|
presets) with filtered- and selected-row **ADIF export**.
|
||||||
- **Find duplicates** (Tools) — groups QSOs by same call + band + mode (optionally
|
- **Find duplicates** (Tools) — groups QSOs by same call + band + mode (optionally
|
||||||
@@ -59,7 +62,10 @@ Developed by **F4BPO**.
|
|||||||
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
||||||
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
|
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
|
||||||
the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
||||||
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by PstRotator.
|
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by
|
||||||
|
**PstRotator** (UDP), a **4O3A Rotator Genius** (native TCP) or a **microHAM
|
||||||
|
ARCO** controlled natively — no PstRotator needed — over the **LAN** or **USB**
|
||||||
|
using its Yaesu GS-232A protocol.
|
||||||
- **Ultrabeam** support (Normal / 180° reverse / Bidirectional): the radiating
|
- **Ultrabeam** support (Normal / 180° reverse / Bidirectional): the radiating
|
||||||
direction is shown in green and the **mechanical boom** in grey, on both the
|
direction is shown in green and the **mechanical boom** in grey, on both the
|
||||||
compass and the map, so you never lose track of where the antenna points.
|
compass and the map, so you never lose track of where the antenna points.
|
||||||
@@ -71,7 +77,9 @@ Developed by **F4BPO**.
|
|||||||
mode / status / source) shared by the Cluster tab and the Main-view cluster
|
mode / status / source) shared by the Cluster tab and the Main-view cluster
|
||||||
pane, with a show/hide toggle.
|
pane, with a show/hide toggle.
|
||||||
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
|
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
|
||||||
rig, and a multi-band **Band Map** (panadapter-style strips).
|
rig, and a multi-band **Band Map** (panadapter-style strips). Optionally, all
|
||||||
|
**digital modes count as one** (DXCC-style) for the new/new-slot colouring and
|
||||||
|
the worked-before matrix badges (Settings → General).
|
||||||
- **POTA** spots are tagged with their park reference (via `api.pota.app`).
|
- **POTA** spots are tagged with their park reference (via `api.pota.app`).
|
||||||
- **Spot alerts** (Log4OM-style): rules on call / country / band / mode /
|
- **Spot alerts** (Log4OM-style): rules on call / country / band / mode /
|
||||||
spotter, with sound, visual and e-mail notification (Tools → *Alert
|
spotter, with sound, visual and e-mail notification (Tools → *Alert
|
||||||
@@ -85,7 +93,8 @@ non-blocking connect so a powered-off radio never freezes the app:
|
|||||||
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
|
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
|
||||||
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
|
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
|
||||||
mode / split, UDP discovery, and **panadapter spots** (cluster spots pushed to
|
mode / split, UDP discovery, and **panadapter spots** (cluster spots pushed to
|
||||||
the Flex display, click → fill the call).
|
the Flex display; a click fills the call and **tunes the rig to the right
|
||||||
|
frequency AND mode**).
|
||||||
- **Icom CI-V** — native, over the radio's **USB** port *or* over the internet
|
- **Icom CI-V** — native, over the radio's **USB** port *or* over the internet
|
||||||
via the radio's **built-in LAN server** (see *Remote Icom* below). No RS-BA1 or
|
via the radio's **built-in LAN server** (see *Remote Icom* below). No RS-BA1 or
|
||||||
Remote Utility needed.
|
Remote Utility needed.
|
||||||
@@ -102,12 +111,16 @@ Shown only when the CAT backend is a FlexRadio:
|
|||||||
|
|
||||||
- **Transmit:** RF power, tune power, TUNE, MOX, speech processor (NOR/DX/DX+),
|
- **Transmit:** RF power, tune power, TUNE, MOX, speech processor (NOR/DX/DX+),
|
||||||
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):** RX/TX **antenna** selectors and a **DAX** toggle
|
||||||
|
(TX audio through DAX, for WSJT-X & co), AGC mode/threshold, audio level,
|
||||||
|
NB / WNB / NR / ANF, and — on **SmartSDR v4** radios (8000 / Aurora series) —
|
||||||
|
the extra DSP tools **NRL, NRS, NRF** (with level) plus **RNN** (AI noise
|
||||||
|
reduction) and **ANFT** (FFT auto-notch), shown automatically when the radio
|
||||||
|
supports them. **RIT / XIT** with wheel / ± tuning.
|
||||||
- **Antenna tuner (ATU):** tune / bypass / memories.
|
- **Antenna tuner (ATU):** tune / bypass / memories.
|
||||||
- **Amplifier:** the amp card follows whichever amplifier is configured —
|
- **Amplifier:** the amp card follows whichever amplifier is configured, with a
|
||||||
**PowerGenius XL** (operate/standby, fan mode, fault) or **SPE Expert**
|
dropdown to pick it when **several amplifiers** are set up (e.g. two SPEs run
|
||||||
(operate/standby, ON/OFF, Low/Mid/High level, output-power bar, band &
|
in parallel). See *Amplifiers & switches* below.
|
||||||
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.
|
||||||
|
|
||||||
@@ -158,14 +171,21 @@ as Mumble.)
|
|||||||
|
|
||||||
## Amplifiers & switches
|
## Amplifiers & switches
|
||||||
|
|
||||||
- **Amplifiers** (Settings → Amplifier — the control card appears on the
|
- **Amplifiers** — configure **one or several** amps (Settings → Amplifier is a
|
||||||
FlexRadio tab, or on its own when neither Flex nor Icom is active):
|
list; e.g. two SPEs run in parallel for more power). Each amp's control card
|
||||||
|
appears on the FlexRadio tab and in **Station Control**, with a dropdown to
|
||||||
|
choose which one it shows; the bottom status bar carries **one clickable chip
|
||||||
|
per amp** (green = OPERATE, orange = STANDBY, red = offline). Supported:
|
||||||
- **PowerGenius XL** (4O3A) over direct TCP — operate/standby, fan-mode
|
- **PowerGenius XL** (4O3A) over direct TCP — operate/standby, fan-mode
|
||||||
selector and fault display.
|
selector and fault display.
|
||||||
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) over **USB** (virtual COM) or the
|
- **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,
|
**network** (RS232-to-Ethernet bridge) — operate/standby, ON/OFF,
|
||||||
Low / Mid / High output level, an output-power bar and live status (band,
|
Low / Mid / High output level, an output-power bar and live status (band,
|
||||||
SWR, PA current, temperature, warnings/alarms).
|
SWR, PA current, temperature, warnings/alarms).
|
||||||
|
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) over **USB** or the **network**
|
||||||
|
(RS232-to-Ethernet bridge) — operate/standby/off and live telemetry (forward
|
||||||
|
& reflected power, SWR, PA temperature, band, fan, faults). Power-ON works
|
||||||
|
over a serial cable that wires the DTR/RTS lines.
|
||||||
- **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**,
|
- **Station Control** panel (dockable, drag-to-reorder widgets): the **rotator**,
|
||||||
@@ -208,10 +228,11 @@ as Mumble.)
|
|||||||
|
|
||||||
## 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**),
|
||||||
Settings → General → *Publish live operator status*. Each OpsLog instance
|
publishing is **automatic** — no setting to turn on. Each OpsLog instance
|
||||||
heartbeats its current activity (operator call, band, frequency, mode) into a
|
heartbeats its current activity (operator call, band, frequency, mode) into a
|
||||||
`live_status` table every ~15 s. A small PHP renderer
|
`live_status` table every ~15 s, and drops back to *off air* automatically 5 min
|
||||||
|
after the last logged QSO. A small PHP renderer
|
||||||
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) on your
|
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) on your
|
||||||
own web server reads that table and produces a live page/image you can embed on
|
own web server reads that table and produces a live page/image you can embed on
|
||||||
the station's **QRZ.com** bio (`<img src="…/tm74-status.php?img=1">`). OpsLog
|
the station's **QRZ.com** bio (`<img src="…/tm74-status.php?img=1">`). OpsLog
|
||||||
@@ -220,8 +241,11 @@ only writes to the DB — it is not a web server.
|
|||||||
## Net control
|
## Net control
|
||||||
|
|
||||||
- **Directed-net logging** (Tools → Net): a global roster (`nets.json`) plus an
|
- **Directed-net logging** (Tools → Net): a global roster (`nets.json`) plus an
|
||||||
in-memory active session — check stations in, then log the whole net at once
|
in-memory active session — check stations in, then log them individually or the
|
||||||
using the CAT frequency.
|
whole net at once (**Log everyone**) using the CAT frequency. **Drag & drop**
|
||||||
|
between the two lists (roster → on-air starts a QSO, on-air → roster logs it),
|
||||||
|
and after each log the next on-air station is selected automatically so you can
|
||||||
|
chain contacts.
|
||||||
|
|
||||||
## Appearance & language
|
## Appearance & language
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import (
|
|||||||
"hamlog/internal/clublog"
|
"hamlog/internal/clublog"
|
||||||
"hamlog/internal/cluster"
|
"hamlog/internal/cluster"
|
||||||
"hamlog/internal/contest"
|
"hamlog/internal/contest"
|
||||||
|
"hamlog/internal/cwdecode"
|
||||||
"hamlog/internal/db"
|
"hamlog/internal/db"
|
||||||
"hamlog/internal/dxcc"
|
"hamlog/internal/dxcc"
|
||||||
"hamlog/internal/email"
|
"hamlog/internal/email"
|
||||||
@@ -136,6 +137,7 @@ const (
|
|||||||
keyAwardEditsSeeded = "awards.defs.editflag" // one-shot: back-fill the user-edited flag
|
keyAwardEditsSeeded = "awards.defs.editflag" // one-shot: back-fill the user-edited flag
|
||||||
|
|
||||||
keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions
|
keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions
|
||||||
|
keyClublogMostWanted = "clublog.most_wanted" // "1" → show ClubLog Most Wanted rank in the entry matrix
|
||||||
|
|
||||||
// E-mail / SMTP — send QSO recordings to the correspondent.
|
// E-mail / SMTP — send QSO recordings to the correspondent.
|
||||||
keyEmailEnabled = "email.enabled"
|
keyEmailEnabled = "email.enabled"
|
||||||
@@ -178,6 +180,8 @@ const (
|
|||||||
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
|
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
|
||||||
keyMotorBaud = "ultrabeam.baud" // serial baud (SteppIR default 9600)
|
keyMotorBaud = "ultrabeam.baud" // serial baud (SteppIR default 9600)
|
||||||
keyMotorTXInhibit = "ultrabeam.tx_inhibit" // "1" → block Flex TX while the antenna is moving
|
keyMotorTXInhibit = "ultrabeam.tx_inhibit" // "1" → block Flex TX while the antenna is moving
|
||||||
|
keyMotorFreqMin = "ultrabeam.freq_min" // SteppIR tunable range low edge (MHz); out-of-range = don't follow/inhibit
|
||||||
|
keyMotorFreqMax = "ultrabeam.freq_max" // SteppIR tunable range high edge (MHz)
|
||||||
keyStationDevices = "station.devices" // JSON list of relay boards for the Station Control tab
|
keyStationDevices = "station.devices" // JSON list of relay boards for the Station Control tab
|
||||||
|
|
||||||
// Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP
|
// Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP
|
||||||
@@ -214,9 +218,11 @@ const (
|
|||||||
keyWKUsePTT = "winkeyer.use_ptt"
|
keyWKUsePTT = "winkeyer.use_ptt"
|
||||||
keyWKSerialEcho = "winkeyer.serial_echo"
|
keyWKSerialEcho = "winkeyer.serial_echo"
|
||||||
keyWKMacros = "winkeyer.macros" // JSON array of {label,text}
|
keyWKMacros = "winkeyer.macros" // JSON array of {label,text}
|
||||||
keyWKEngine = "winkeyer.engine" // "winkeyer" | "icom" | "tci"
|
keyWKEngine = "winkeyer.engine" // "winkeyer" | "serial" | "icom" | "flex" | "tci"
|
||||||
keyWKEscClears = "winkeyer.esc_clears_call" // ESC also clears the callsign
|
keyWKEscClears = "winkeyer.esc_clears_call" // ESC also clears the callsign
|
||||||
keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed
|
keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed
|
||||||
|
keyWKCWLine = "winkeyer.cw_key_line" // serial engine: "dtr" (CW) / "rts" (PTT) or swapped
|
||||||
|
keyWKCWInvert = "winkeyer.cw_invert" // serial engine: invert line polarity (active-LOW)
|
||||||
|
|
||||||
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
|
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
|
||||||
|
|
||||||
@@ -462,6 +468,7 @@ type App struct {
|
|||||||
extsvc *extsvc.Manager
|
extsvc *extsvc.Manager
|
||||||
winkeyer *winkeyer.Manager
|
winkeyer *winkeyer.Manager
|
||||||
clublog *clublog.Manager
|
clublog *clublog.Manager
|
||||||
|
clublogMW *clublog.MostWanted // ClubLog "Most Wanted" DXCC ranking (opt-in)
|
||||||
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
|
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
|
||||||
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
|
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
|
||||||
motorInhibStop chan struct{} // stops the "inhibit TX while moving" loop; nil when off
|
motorInhibStop chan struct{} // stops the "inhibit TX while moving" loop; nil when off
|
||||||
@@ -488,6 +495,11 @@ type App struct {
|
|||||||
|
|
||||||
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
|
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
|
||||||
|
|
||||||
|
cwMu sync.Mutex // guards the CW decoder lifecycle
|
||||||
|
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
|
||||||
|
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
|
||||||
|
cwPitchHz int // manual pitch override (0 = auto / follow Flex)
|
||||||
|
|
||||||
startupProfile string // --profile <name> from the command line (activate at startup)
|
startupProfile string // --profile <name> from the command line (activate at startup)
|
||||||
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
||||||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
||||||
@@ -503,8 +515,9 @@ type App struct {
|
|||||||
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
||||||
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
|
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
|
||||||
startupErr string // captured for surfacing to the frontend
|
startupErr string // captured for surfacing to the frontend
|
||||||
dbPath string // active database file (may be a user-chosen location)
|
dbPath string // settings/config database file (settings + profiles); may be a user-chosen location
|
||||||
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
|
logbookPath string // default SQLite logbook file (QSOs), next to the settings db — used when a profile doesn't point elsewhere
|
||||||
|
logDb *sql.DB // QSO logbook connection — MySQL, a per-profile SQLite file, or the default logbook.db (never the settings db, except on fallback)
|
||||||
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
|
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
|
||||||
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
|
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
|
||||||
offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable
|
offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable
|
||||||
@@ -699,7 +712,16 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.dataDir = dataDir
|
a.dataDir = dataDir
|
||||||
a.dbPath = filepath.Join(dataDir, "opslog.db")
|
// Settings/config database (settings + profiles). Fresh installs use
|
||||||
|
// settings.db; existing installs keep their opslog.db (which also held the
|
||||||
|
// QSOs before they were split into a dedicated logbook file — see below).
|
||||||
|
settingsDefault := filepath.Join(dataDir, "settings.db")
|
||||||
|
legacyOpslog := filepath.Join(dataDir, "opslog.db")
|
||||||
|
if fileExists(legacyOpslog) && !fileExists(settingsDefault) {
|
||||||
|
a.dbPath = legacyOpslog
|
||||||
|
} else {
|
||||||
|
a.dbPath = settingsDefault
|
||||||
|
}
|
||||||
usingDefault := true
|
usingDefault := true
|
||||||
// config.json (in the data dir) may point the database to a user-chosen
|
// config.json (in the data dir) may point the database to a user-chosen
|
||||||
// location — e.g. another drive or a synced folder, so it survives a
|
// location — e.g. another drive or a synced folder, so it survives a
|
||||||
@@ -771,6 +793,23 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
a.db = conn
|
a.db = conn
|
||||||
|
|
||||||
|
// The QSO logbook lives in its OWN file (logbook.db) next to the settings db,
|
||||||
|
// so QSOs never share the settings/profiles database. One-time split for
|
||||||
|
// existing installs: if there's no logbook file yet but the settings db
|
||||||
|
// already holds QSOs (the legacy combined opslog.db), seed logbook.db with a
|
||||||
|
// clean copy (VACUUM INTO) — the contacts move to the logbook while the
|
||||||
|
// originals stay in the settings db as an untouched backup. Non-destructive.
|
||||||
|
a.logbookPath = filepath.Join(filepath.Dir(a.dbPath), "logbook.db")
|
||||||
|
if !fileExists(a.logbookPath) && sqliteHasQSOs(a.db) {
|
||||||
|
esc := strings.ReplaceAll(a.logbookPath, "'", "''")
|
||||||
|
if _, verr := a.db.Exec("VACUUM INTO '" + esc + "'"); verr != nil {
|
||||||
|
applog.Printf("logbook split: VACUUM INTO %s failed (%v) — the settings db will serve as the logbook", a.logbookPath, verr)
|
||||||
|
a.logbookPath = "" // fall back to using the settings db as the logbook
|
||||||
|
} else {
|
||||||
|
fmt.Printf("OpsLog: split logbook — seeded %s from the existing database (originals kept as backup)\n", a.logbookPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wire the LOCAL config repos first — they're backed by the already-open
|
// Wire the LOCAL config repos first — they're backed by the already-open
|
||||||
// SQLite file, so the station/profiles/settings are ready instantly. Doing
|
// SQLite file, so the station/profiles/settings are ready instantly. Doing
|
||||||
// this BEFORE the (possibly slow, remote) MySQL logbook connect means the UI
|
// this BEFORE the (possibly slow, remote) MySQL logbook connect means the UI
|
||||||
@@ -952,6 +991,30 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// ClubLog "Most Wanted" DXCC ranking (opt-in, Settings → General). Loaded from
|
||||||
|
// the cached JSON, then refreshed daily and whenever the operator's callsign
|
||||||
|
// changes (the ranking is personalised per callsign). Best-effort.
|
||||||
|
a.clublogMW = clublog.NewMostWanted(dataDir)
|
||||||
|
go func() {
|
||||||
|
if a.settings == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v, _ := a.settings.Get(a.ctx, keyClublogMostWanted); v != "1" {
|
||||||
|
return // feature off — don't touch the network
|
||||||
|
}
|
||||||
|
_ = a.clublogMW.LoadFromDisk()
|
||||||
|
call := a.activeCallsign()
|
||||||
|
if a.clublogMW.NeedsRefresh(call, 24*time.Hour) {
|
||||||
|
if err := a.clublogMW.Download(context.Background(), call); err != nil {
|
||||||
|
applog.Printf("clublog most-wanted: auto-refresh failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.clublogMW.Loaded() {
|
||||||
|
c, d, n := a.clublogMW.Info()
|
||||||
|
applog.Printf("clublog most-wanted: loaded %d entities for %s (%s)", n, c, d)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// POTA: background poller of api.pota.app so cluster spots can be tagged
|
// POTA: background poller of api.pota.app so cluster spots can be tagged
|
||||||
// when the DX station is currently activating a park. Best-effort.
|
// when the DX station is currently activating a park. Best-effort.
|
||||||
a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) })
|
a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) })
|
||||||
@@ -1506,15 +1569,39 @@ func writeDBPointer(dataDir, path string) error {
|
|||||||
|
|
||||||
// DatabaseSettings describes the active database file for the Settings UI.
|
// DatabaseSettings describes the active database file for the Settings UI.
|
||||||
type DatabaseSettings struct {
|
type DatabaseSettings struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"` // settings/config database (settings + profiles)
|
||||||
DefaultPath string `json:"default_path"`
|
DefaultPath string `json:"default_path"` // where the settings db lives by default
|
||||||
IsCustom bool `json:"is_custom"`
|
IsCustom bool `json:"is_custom"` // config.json points it elsewhere
|
||||||
|
LogbookDefaultPath string `json:"logbook_default_path"` // default SQLite logbook file (QSOs), when a profile doesn't point elsewhere
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDatabaseSettings returns where the active database lives.
|
// GetDatabaseSettings returns where the active database lives.
|
||||||
func (a *App) GetDatabaseSettings() DatabaseSettings {
|
func (a *App) GetDatabaseSettings() DatabaseSettings {
|
||||||
def := filepath.Join(a.dataDir, "opslog.db")
|
// Default settings-db location: settings.db on fresh installs, opslog.db when
|
||||||
return DatabaseSettings{Path: a.dbPath, DefaultPath: def, IsCustom: a.dbPath != def}
|
// an existing one is present (mirrors the startup resolution).
|
||||||
|
settingsDefault := filepath.Join(a.dataDir, "settings.db")
|
||||||
|
legacyOpslog := filepath.Join(a.dataDir, "opslog.db")
|
||||||
|
def := settingsDefault
|
||||||
|
if fileExists(legacyOpslog) && !fileExists(settingsDefault) {
|
||||||
|
def = legacyOpslog
|
||||||
|
}
|
||||||
|
lp := a.logbookPath
|
||||||
|
if lp == "" {
|
||||||
|
lp = a.dbPath // split disabled → the settings db doubles as the logbook
|
||||||
|
}
|
||||||
|
return DatabaseSettings{Path: a.dbPath, DefaultPath: def, IsCustom: a.dbPath != def, LogbookDefaultPath: lp}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevealDataFolder opens the folder that holds the settings database (and the
|
||||||
|
// default logbook) in the OS file manager — the "where is my data" shortcut.
|
||||||
|
func (a *App) RevealDataFolder() error {
|
||||||
|
dir := filepath.Dir(a.dbPath)
|
||||||
|
return openInFileManager(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openInFileManager opens a folder in Windows Explorer (matches OpenAwardsFolder).
|
||||||
|
func openInFileManager(dir string) error {
|
||||||
|
return exec.Command("explorer", dir).Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MySQLSettings is the shared-database (multi-operator) connection config. When
|
// MySQLSettings is the shared-database (multi-operator) connection config. When
|
||||||
@@ -1527,6 +1614,9 @@ type MySQLSettings struct {
|
|||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Database string `json:"database"`
|
Database string `json:"database"`
|
||||||
|
// SqlitePath, when set (and Enabled=false), routes this profile's logbook to
|
||||||
|
// its OWN SQLite file instead of the shared app database. Empty = shared.
|
||||||
|
SqlitePath string `json:"sqlite_path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DBBackendStatus reports which backend OpsLog actually opened at startup so
|
// DBBackendStatus reports which backend OpsLog actually opened at startup so
|
||||||
@@ -1584,8 +1674,39 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
|
|||||||
}
|
}
|
||||||
return c, "mysql", nil
|
return c, "mysql", nil
|
||||||
}
|
}
|
||||||
|
// SQLite logbook FILE, separate from the settings/config database. A profile
|
||||||
|
// may point at its own file (cfg.Path, e.g. a visiting operator's log); with
|
||||||
|
// no path it uses the default logbook.db beside the settings db. db.Open
|
||||||
|
// creates + migrates the file if it doesn't exist yet. Only when there is no
|
||||||
|
// default logbook path at all (VACUUM-INTO split failed) do we fall back to the
|
||||||
|
// settings db itself as the logbook.
|
||||||
|
lp := strings.TrimSpace(cfg.Path)
|
||||||
|
if lp == "" {
|
||||||
|
lp = a.logbookPath
|
||||||
|
}
|
||||||
|
if lp == "" {
|
||||||
return a.db, "sqlite", nil
|
return a.db, "sqlite", nil
|
||||||
}
|
}
|
||||||
|
c, err := db.Open(lp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("open logbook %s: %w", lp, err)
|
||||||
|
}
|
||||||
|
return c, "sqlite", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sqliteHasQSOs reports whether the given (SQLite) database has at least one QSO
|
||||||
|
// row — used once at startup to decide whether to seed the split-out logbook
|
||||||
|
// file from a legacy combined database. Missing table / any error → false.
|
||||||
|
func sqliteHasQSOs(conn *sql.DB) bool {
|
||||||
|
if conn == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := conn.QueryRow("SELECT EXISTS(SELECT 1 FROM qso)").Scan(&n); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return n > 0
|
||||||
|
}
|
||||||
|
|
||||||
// adoptBootstrapMySQL migrates a legacy config.json MySQL config into the active
|
// adoptBootstrapMySQL migrates a legacy config.json MySQL config into the active
|
||||||
// profile (one-time), so users who set up MySQL before it became per-profile
|
// profile (one-time), so users who set up MySQL before it became per-profile
|
||||||
@@ -1648,6 +1769,7 @@ func (a *App) GetMySQLSettings() (MySQLSettings, error) {
|
|||||||
d := p.DB
|
d := p.DB
|
||||||
out.Enabled = d.Backend == "mysql"
|
out.Enabled = d.Backend == "mysql"
|
||||||
out.Host, out.User, out.Password, out.Database = d.Host, d.User, d.Password, d.Database
|
out.Host, out.User, out.Password, out.Database = d.Host, d.User, d.Password, d.Database
|
||||||
|
out.SqlitePath = d.Path
|
||||||
if d.Port > 0 {
|
if d.Port > 0 {
|
||||||
out.Port = d.Port
|
out.Port = d.Port
|
||||||
}
|
}
|
||||||
@@ -1672,6 +1794,10 @@ func (a *App) SaveMySQLSettings(s MySQLSettings) error {
|
|||||||
cfg.Port = s.Port
|
cfg.Port = s.Port
|
||||||
cfg.User = strings.TrimSpace(s.User)
|
cfg.User = strings.TrimSpace(s.User)
|
||||||
cfg.Password = s.Password
|
cfg.Password = s.Password
|
||||||
|
} else if sp := strings.TrimSpace(s.SqlitePath); sp != "" {
|
||||||
|
// Separate per-profile SQLite logbook file (config stays in opslog.db).
|
||||||
|
cfg.Backend = "sqlite"
|
||||||
|
cfg.Path = sp
|
||||||
}
|
}
|
||||||
if err := a.profiles.SetDB(a.ctx, p.ID, cfg); err != nil {
|
if err := a.profiles.SetDB(a.ctx, p.ID, cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1680,6 +1806,61 @@ func (a *App) SaveMySQLSettings(s MySQLSettings) error {
|
|||||||
return a.switchLogbook(p)
|
return a.switchLogbook(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenameLogbook copies the ACTIVE profile's SQLite logbook to dest (with its
|
||||||
|
// QSOs), repoints the profile at it and switches the live logbook — no restart.
|
||||||
|
// Unlike "choose a dedicated file" (which points at a fresh/empty file), this
|
||||||
|
// carries the data across. The old file is deleted only when it was this
|
||||||
|
// profile's OWN dedicated file; the shared default logbook.db is left in place
|
||||||
|
// (other profiles may use it). Errors if the logbook is MySQL.
|
||||||
|
func (a *App) RenameLogbook(dest string) error {
|
||||||
|
dest = strings.TrimSpace(dest)
|
||||||
|
if dest == "" {
|
||||||
|
return fmt.Errorf("no destination given")
|
||||||
|
}
|
||||||
|
p, err := a.profiles.Active(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("no active profile: %w", err)
|
||||||
|
}
|
||||||
|
if p.DB.Backend == "mysql" {
|
||||||
|
return fmt.Errorf("this profile's logbook is MySQL — no file to rename")
|
||||||
|
}
|
||||||
|
old := strings.TrimSpace(p.DB.Path)
|
||||||
|
wasDedicated := old != ""
|
||||||
|
if old == "" {
|
||||||
|
old = a.logbookPath
|
||||||
|
}
|
||||||
|
if old == "" || a.logDb == nil {
|
||||||
|
return fmt.Errorf("no logbook file to rename")
|
||||||
|
}
|
||||||
|
if strings.EqualFold(filepath.Clean(dest), filepath.Clean(old)) {
|
||||||
|
return fmt.Errorf("that is already the current logbook name")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dest); err == nil {
|
||||||
|
return fmt.Errorf("a file already exists at %s — pick a new name", dest)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create folder: %w", err)
|
||||||
|
}
|
||||||
|
safe := strings.ReplaceAll(dest, "'", "''")
|
||||||
|
if _, err := a.logDb.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil {
|
||||||
|
return fmt.Errorf("copy logbook: %w", err)
|
||||||
|
}
|
||||||
|
p.DB.Backend = "sqlite"
|
||||||
|
p.DB.Path = dest
|
||||||
|
if err := a.profiles.SetDB(a.ctx, p.ID, p.DB); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.switchLogbook(p); err != nil { // opens dest, closes the old conn
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if wasDedicated {
|
||||||
|
for _, f := range []string{old, old + "-wal", old + "-shm"} {
|
||||||
|
_ = os.Remove(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// TestMySQLConnection pings the shared MySQL database with the given settings
|
// TestMySQLConnection pings the shared MySQL database with the given settings
|
||||||
// (no migrations) so the user can validate connectivity from the UI.
|
// (no migrations) so the user can validate connectivity from the UI.
|
||||||
func (a *App) TestMySQLConnection(s MySQLSettings) error {
|
func (a *App) TestMySQLConnection(s MySQLSettings) error {
|
||||||
@@ -1833,7 +2014,14 @@ func (a *App) groupDigitalSlots() bool {
|
|||||||
|
|
||||||
func (a *App) GetUIPref(key string) (string, error) {
|
func (a *App) GetUIPref(key string) (string, error) {
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return "", nil
|
// Distinct from a genuinely-empty pref: the (LOCAL SQLite) settings store
|
||||||
|
// isn't wired yet. There's a brief window at launch where the frontend can
|
||||||
|
// call this before OnStartup has opened the DB and built the store. The UI
|
||||||
|
// uses the error to keep RETRYING rather than treat it as "unset" and fall
|
||||||
|
// back to a default — the "dark theme reverts to light after an update" bug
|
||||||
|
// (the update cleared localStorage, and the DB read gave up too early while
|
||||||
|
// the store was still coming up).
|
||||||
|
return "", fmt.Errorf("settings store not ready")
|
||||||
}
|
}
|
||||||
return a.settings.Get(a.ctx, "ui."+key)
|
return a.settings.Get(a.ctx, "ui."+key)
|
||||||
}
|
}
|
||||||
@@ -1983,6 +2171,12 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
q.ID = id
|
q.ID = id
|
||||||
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh (in-memory)
|
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh (in-memory)
|
||||||
a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
|
a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
|
||||||
|
// Snapshot the QSO recording SYNCHRONOUSLY, BEFORE announcing the log: the
|
||||||
|
// qso:logged event clears the entry form, and clearing the callsign cancels
|
||||||
|
// the recorder — so if this ran on the async path below it raced the cancel
|
||||||
|
// and the audio was discarded (recordings silently stopped working). The
|
||||||
|
// snapshot is an in-memory copy; the heavy file encode still runs async.
|
||||||
|
a.saveQSORecording(&q)
|
||||||
// Announce the log RIGHT AWAY so the grid/UI refresh at once and the entry
|
// Announce the log RIGHT AWAY so the grid/UI refresh at once and the entry
|
||||||
// form clears immediately — the operator is not made to wait on the DB.
|
// form clears immediately — the operator is not made to wait on the DB.
|
||||||
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
||||||
@@ -1994,7 +2188,6 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
qc := q
|
qc := q
|
||||||
go func() {
|
go func() {
|
||||||
a.materializeAwardRefs(qc) // fills the award_refs column
|
a.materializeAwardRefs(qc) // fills the award_refs column
|
||||||
a.saveQSORecording(&qc)
|
|
||||||
if a.extsvc != nil {
|
if a.extsvc != nil {
|
||||||
a.extsvc.OnQSOLogged(id)
|
a.extsvc.OnQSOLogged(id)
|
||||||
}
|
}
|
||||||
@@ -5166,6 +5359,11 @@ func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) {
|
|||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return 0, fmt.Errorf("db not initialized")
|
return 0, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
|
// Frequency is numeric and drives the band, so it takes a dedicated path
|
||||||
|
// (freq_hz + band updated together) rather than the generic text setter.
|
||||||
|
if field == "freq" {
|
||||||
|
return a.bulkSetFrequency(ids, value)
|
||||||
|
}
|
||||||
col, ok := bulkFieldColumns[field]
|
col, ok := bulkFieldColumns[field]
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, fmt.Errorf("unknown field %q", field)
|
return 0, fmt.Errorf("unknown field %q", field)
|
||||||
@@ -5184,6 +5382,34 @@ func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) {
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bulkSetFrequency parses a MHz frequency and sets freq_hz + band across the
|
||||||
|
// selected QSOs. Accepts a comma or dot decimal separator (fr locale) and
|
||||||
|
// rejects anything outside the ham bands so a typo can't corrupt the batch.
|
||||||
|
func (a *App) bulkSetFrequency(ids []int64, value string) (int64, error) {
|
||||||
|
s := strings.ReplaceAll(strings.TrimSpace(value), ",", ".")
|
||||||
|
if s == "" {
|
||||||
|
return 0, fmt.Errorf("enter a frequency in MHz (e.g. 7.155)")
|
||||||
|
}
|
||||||
|
mhz, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil || mhz <= 0 {
|
||||||
|
return 0, fmt.Errorf("invalid frequency %q — enter MHz, e.g. 7.155", value)
|
||||||
|
}
|
||||||
|
hz := int64(math.Round(mhz * 1_000_000))
|
||||||
|
band := cat.BandFromHz(hz)
|
||||||
|
if band == "" {
|
||||||
|
return 0, fmt.Errorf("%.4f MHz is not in a ham band", mhz)
|
||||||
|
}
|
||||||
|
n, err := a.qso.BulkSetFrequency(a.ctx, ids, hz, band)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
a.invalidateAwardStats()
|
||||||
|
a.materializeAwardRefsForIDs(ids) // band change may shift award slots → refresh
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
// WorkedBefore returns prior contacts with the given callsign at both
|
// WorkedBefore returns prior contacts with the given callsign at both
|
||||||
// call and DXCC granularity. Pass dxccHint=0 when unknown — the function
|
// call and DXCC granularity. Pass dxccHint=0 when unknown — the function
|
||||||
// will infer it from past QSOs with the same call when possible.
|
// will infer it from past QSOs with the same call when possible.
|
||||||
@@ -5200,7 +5426,13 @@ func (a *App) WorkedBefore(callsign string, dxccHint int) (qso.WorkedBefore, err
|
|||||||
dxccHint = dxcc.EntityDXCC(m.Entity.Name)
|
dxccHint = dxcc.EntityDXCC(m.Entity.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return a.qso.WorkedBefore(a.ctx, callsign, dxccHint)
|
wb, err := a.qso.WorkedBefore(a.ctx, callsign, dxccHint)
|
||||||
|
// Attach the ClubLog Most Wanted rank for this entity (opt-in) so the entry
|
||||||
|
// matrix can show it next to the country name.
|
||||||
|
if err == nil && wb.DXCC > 0 && a.clublogMW != nil && a.clublogMostWantedEnabled() {
|
||||||
|
wb.MWRank = a.clublogMW.Rank(wb.DXCC)
|
||||||
|
}
|
||||||
|
return wb, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCompactMode toggles a tiny always-on-top window that exposes just the
|
// SetCompactMode toggles a tiny always-on-top window that exposes just the
|
||||||
@@ -5380,37 +5612,55 @@ func (a *App) ADIFFields() []adif.FieldDef { return adif.Fields }
|
|||||||
// ADIFVersion returns the ADIF spec version OpsLog conforms to (e.g. "3.1.7").
|
// ADIFVersion returns the ADIF spec version OpsLog conforms to (e.g. "3.1.7").
|
||||||
func (a *App) ADIFVersion() string { return adif.ADIFVersion() }
|
func (a *App) ADIFVersion() string { return adif.ADIFVersion() }
|
||||||
|
|
||||||
|
// adifFieldSet turns a list of ADIF tag names into an allow-set (uppercased),
|
||||||
|
// or nil when the list is empty — nil means "all fields" (standard/full mode),
|
||||||
|
// so the two-mode export path is unchanged when no explicit field list is given.
|
||||||
|
func adifFieldSet(fields []string) map[string]bool {
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
set := make(map[string]bool, len(fields))
|
||||||
|
for _, f := range fields {
|
||||||
|
if t := strings.ToUpper(strings.TrimSpace(f)); t != "" {
|
||||||
|
set[t] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
// ExportADIF writes every QSO to the given file path in ADIF 3.1 format.
|
// ExportADIF writes every QSO to the given file path in ADIF 3.1 format.
|
||||||
// Streams from DB so memory stays flat even with 100k+ records.
|
// Streams from DB so memory stays flat even with 100k+ records.
|
||||||
// includeAppFields=false → portable standard ADIF (for other loggers);
|
// includeAppFields=false → portable standard ADIF (for other loggers);
|
||||||
// true → full export keeping OpsLog/app-specific APP_* fields (round-trip).
|
// true → full export keeping OpsLog/app-specific APP_* fields (round-trip).
|
||||||
func (a *App) ExportADIF(path string, includeAppFields bool) (adif.ExportResult, error) {
|
// fields (optional): when non-empty, export EXACTLY those ADIF tags — the
|
||||||
|
// "choose which fields to export" mode (overrides includeAppFields).
|
||||||
|
func (a *App) ExportADIF(path string, includeAppFields bool, fields []string) (adif.ExportResult, error) {
|
||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return adif.ExportResult{}, fmt.Errorf("empty path")
|
return adif.ExportResult{}, fmt.Errorf("empty path")
|
||||||
}
|
}
|
||||||
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
|
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields, Fields: adifFieldSet(fields)}
|
||||||
return ex.ExportFile(a.ctx, path)
|
return ex.ExportFile(a.ctx, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportADIFFiltered writes the QSOs matching the current filter to path, with
|
// ExportADIFFiltered writes the QSOs matching the current filter to path, with
|
||||||
// NO row limit (the on-screen list is capped by the threshold; this is not).
|
// NO row limit (the on-screen list is capped by the threshold; this is not).
|
||||||
func (a *App) ExportADIFFiltered(path string, includeAppFields bool, f qso.QueryFilter) (adif.ExportResult, error) {
|
func (a *App) ExportADIFFiltered(path string, includeAppFields bool, f qso.QueryFilter, fields []string) (adif.ExportResult, error) {
|
||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return adif.ExportResult{}, fmt.Errorf("empty path")
|
return adif.ExportResult{}, fmt.Errorf("empty path")
|
||||||
}
|
}
|
||||||
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
|
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields, Fields: adifFieldSet(fields)}
|
||||||
return ex.ExportFileFiltered(a.ctx, path, f)
|
return ex.ExportFileFiltered(a.ctx, path, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportADIFSelected writes only the QSOs whose ids are given (the rows the
|
// ExportADIFSelected writes only the QSOs whose ids are given (the rows the
|
||||||
// operator highlighted in the grid).
|
// operator highlighted in the grid).
|
||||||
func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64) (adif.ExportResult, error) {
|
func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64, fields []string) (adif.ExportResult, error) {
|
||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
return adif.ExportResult{}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
@@ -5420,10 +5670,32 @@ func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64
|
|||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return adif.ExportResult{}, fmt.Errorf("no QSOs selected")
|
return adif.ExportResult{}, fmt.Errorf("no QSOs selected")
|
||||||
}
|
}
|
||||||
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
|
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields, Fields: adifFieldSet(fields)}
|
||||||
return ex.ExportFileByIDs(a.ctx, path, ids)
|
return ex.ExportFileByIDs(a.ctx, path, ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ADIFExtraFields returns the distinct NON-standard ADIF tags actually present
|
||||||
|
// in the logbook's stored extras (APP_* and vendor tags) — the "OpsLog / extra
|
||||||
|
// fields" group in the export field picker, alongside the static official-ADIF
|
||||||
|
// list from ADIFFields(). Sampled + capped so it stays fast on a big remote log.
|
||||||
|
func (a *App) ADIFExtraFields() []string {
|
||||||
|
if a.qso == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
keys, err := a.qso.DistinctExtraKeys(a.ctx, 5000)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
if t := strings.ToUpper(strings.TrimSpace(k)); t != "" && !adif.IsStandardField(t) {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// CabrilloResult reports how many QSOs a Cabrillo export wrote and where.
|
// CabrilloResult reports how many QSOs a Cabrillo export wrote and where.
|
||||||
type CabrilloResult struct {
|
type CabrilloResult struct {
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
@@ -6226,19 +6498,14 @@ func (a *App) saveQSORecording(q *qso.QSO) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stamp the recording's path on the QSO now, synchronously, so it's set
|
// Stamp the recording's path on the in-memory QSO now (the encode goroutine
|
||||||
// before the eQSL auto-send reads the QSO (their full-row Updates would
|
// reads it). The DB write is deferred into that goroutine so this stays OFF
|
||||||
// otherwise race and clobber each other's extras).
|
// the critical logging path — saveQSORecording is now called synchronously.
|
||||||
if q.ID != 0 {
|
if q.ID != 0 {
|
||||||
if q.Extras == nil {
|
if q.Extras == nil {
|
||||||
q.Extras = map[string]string{}
|
q.Extras = map[string]string{}
|
||||||
}
|
}
|
||||||
q.Extras["APP_OPSLOG_RECORDING"] = name // in-memory copy for the encode goroutine
|
q.Extras["APP_OPSLOG_RECORDING"] = name
|
||||||
// Persist ONLY this extras key (targeted) — a full-row Update from this
|
|
||||||
// in-memory copy could revert a column a concurrent post-log action changed.
|
|
||||||
if err := a.qso.SetExtra(a.ctx, q.ID, "APP_OPSLOG_RECORDING", name); err != nil {
|
|
||||||
applog.Printf("qso-rec: store recording path: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone the QSO for the goroutine with its OWN copy of the Extras map: a
|
// Clone the QSO for the goroutine with its OWN copy of the Extras map: a
|
||||||
@@ -6264,6 +6531,13 @@ func (a *App) saveQSORecording(q *qso.QSO) {
|
|||||||
applog.Printf("qso-rec: save failed: %v", err)
|
applog.Printf("qso-rec: save failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Persist the recording path (targeted extras write) now that the file
|
||||||
|
// exists — off the critical path, so a busy MySQL never delays logging.
|
||||||
|
if qc.ID != 0 {
|
||||||
|
if err := a.qso.SetExtra(a.ctx, qc.ID, "APP_OPSLOG_RECORDING", name); err != nil {
|
||||||
|
applog.Printf("qso-rec: store recording path: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
applog.Printf("qso-rec: saved %s", path)
|
applog.Printf("qso-rec: saved %s", path)
|
||||||
// Auto-send the recording once the file exists. Gated ONLY on its own
|
// Auto-send the recording once the file exists. Gated ONLY on its own
|
||||||
// "auto-send recording" toggle (email.auto_send) — NOT the SMTP panel's
|
// "auto-send recording" toggle (email.auto_send) — NOT the SMTP panel's
|
||||||
@@ -6484,11 +6758,15 @@ func (a *App) clusterEventWorker() {
|
|||||||
}
|
}
|
||||||
// Fire any matching alert rules (sound / visual / e-mail).
|
// Fire any matching alert rules (sound / visual / e-mail).
|
||||||
a.evaluateAlerts(s)
|
a.evaluateAlerts(s)
|
||||||
// Mirror the spot onto the FlexRadio panadapter when enabled.
|
// Mirror the spot onto the FlexRadio panadapter when enabled. Infer the
|
||||||
|
// mode (from the comment, else the band plan) so clicking the spot on the
|
||||||
|
// panadapter tunes AND switches mode — a DX cluster line carries no mode,
|
||||||
|
// so without this the radio only moved frequency.
|
||||||
if a.catFlexSpots && a.cat != nil {
|
if a.catFlexSpots && a.cat != nil {
|
||||||
a.cat.SendSpot(cat.SpotInfo{
|
a.cat.SendSpot(cat.SpotInfo{
|
||||||
FreqHz: s.FreqHz,
|
FreqHz: s.FreqHz,
|
||||||
Callsign: s.DXCall,
|
Callsign: s.DXCall,
|
||||||
|
Mode: alerts.InferMode(s.Comment, s.FreqHz),
|
||||||
Comment: s.Comment,
|
Comment: s.Comment,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -7167,6 +7445,55 @@ func (a *App) SendQSORecordingEmail(id int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// developerEmail is where "Help → Send log to F4BPO" delivers diagnostic logs.
|
||||||
|
const developerEmail = "[email protected]"
|
||||||
|
|
||||||
|
// SMTPConfigured reports whether an SMTP server is set up, so the UI can show the
|
||||||
|
// "Send log to F4BPO" help entry only when there's actually a way to send.
|
||||||
|
func (a *App) SMTPConfigured() bool {
|
||||||
|
s, _ := a.GetEmailSettings()
|
||||||
|
return strings.TrimSpace(s.Host) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendLogToDeveloper e-mails the app's diagnostic logs (opslog.log, the previous
|
||||||
|
// session's opslog.log.1, and any crash log) to the developer so problems users
|
||||||
|
// hit in the field can be diagnosed. Requires a configured SMTP server
|
||||||
|
// (Settings → E-mail). A short header identifies the sender's call and version.
|
||||||
|
func (a *App) SendLogToDeveloper() error {
|
||||||
|
s, _ := a.GetEmailSettings()
|
||||||
|
if strings.TrimSpace(s.Host) == "" {
|
||||||
|
return fmt.Errorf("no SMTP server configured — set one in Settings → E-mail")
|
||||||
|
}
|
||||||
|
logPath := applog.Path()
|
||||||
|
if logPath == "" {
|
||||||
|
return fmt.Errorf("log file not available")
|
||||||
|
}
|
||||||
|
// The current log, plus the rotated one and the crash log when they exist.
|
||||||
|
attach := []string{logPath}
|
||||||
|
for _, extra := range []string{logPath + ".1", filepath.Join(filepath.Dir(logPath), "opslog-crash.log")} {
|
||||||
|
if _, err := os.Stat(extra); err == nil {
|
||||||
|
attach = append(attach, extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
call := ""
|
||||||
|
if a.profiles != nil {
|
||||||
|
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||||
|
call = p.Callsign
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subject := fmt.Sprintf("OpsLog log — v%s", appVersion)
|
||||||
|
if call != "" {
|
||||||
|
subject = fmt.Sprintf("OpsLog log — %s v%s", call, appVersion)
|
||||||
|
}
|
||||||
|
body := fmt.Sprintf("OpsLog diagnostic log attached.\n\nCallsign: %s\nVersion: %s\n\n73", call, appVersion)
|
||||||
|
if err := email.SendFiles(a.emailConfig(s), developerEmail, subject, body, attach); err != nil {
|
||||||
|
applog.Printf("email: send log to developer failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
applog.Printf("email: diagnostic log sent to developer (%s)", developerEmail)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ── ClubLog Country File (cty.xml) exceptions ─────────────────────────
|
// ── ClubLog Country File (cty.xml) exceptions ─────────────────────────
|
||||||
|
|
||||||
// ClublogCtyInfo is the UI status of the ClubLog exception data.
|
// ClublogCtyInfo is the UI status of the ClubLog exception data.
|
||||||
@@ -7225,6 +7552,74 @@ func (a *App) DownloadClublogCty() (ClublogCtyInfo, error) {
|
|||||||
return a.GetClublogCtyInfo(), nil
|
return a.GetClublogCtyInfo(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// activeCallsign is the current profile's callsign (upper-cased), "" if none.
|
||||||
|
func (a *App) activeCallsign() string {
|
||||||
|
if a.profiles == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||||
|
return strings.ToUpper(strings.TrimSpace(p.Callsign))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) clublogMostWantedEnabled() bool {
|
||||||
|
if a.settings == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, _ := a.settings.Get(a.ctx, keyClublogMostWanted)
|
||||||
|
return v == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClublogMostWantedInfo is the UI status of the ClubLog Most Wanted data.
|
||||||
|
type ClublogMostWantedInfo struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Loaded bool `json:"loaded"`
|
||||||
|
Callsign string `json:"callsign"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClublogMostWantedInfo returns the current Most Wanted status.
|
||||||
|
func (a *App) GetClublogMostWantedInfo() ClublogMostWantedInfo {
|
||||||
|
info := ClublogMostWantedInfo{Enabled: a.clublogMostWantedEnabled()}
|
||||||
|
if a.clublogMW != nil {
|
||||||
|
info.Loaded = a.clublogMW.Loaded()
|
||||||
|
info.Callsign, info.Date, info.Count = a.clublogMW.Info()
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetClublogMostWantedEnabled toggles the Most Wanted feature. On first enable it
|
||||||
|
// loads the cached list (the UI then calls Download to fetch a fresh one).
|
||||||
|
func (a *App) SetClublogMostWantedEnabled(on bool) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
v := "0"
|
||||||
|
if on {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
if err := a.settings.Set(a.ctx, keyClublogMostWanted, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if on && a.clublogMW != nil && !a.clublogMW.Loaded() {
|
||||||
|
_ = a.clublogMW.LoadFromDisk() // ok if not downloaded yet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadClublogMostWanted fetches a fresh Most Wanted list for the active call.
|
||||||
|
func (a *App) DownloadClublogMostWanted() (ClublogMostWantedInfo, error) {
|
||||||
|
if a.clublogMW == nil {
|
||||||
|
return ClublogMostWantedInfo{}, fmt.Errorf("clublog not initialized")
|
||||||
|
}
|
||||||
|
if err := a.clublogMW.Download(a.ctx, a.activeCallsign()); err != nil {
|
||||||
|
return a.GetClublogMostWantedInfo(), err
|
||||||
|
}
|
||||||
|
return a.GetClublogMostWantedInfo(), nil
|
||||||
|
}
|
||||||
|
|
||||||
// applyClublogException overrides a QSO's entity fields from a ClubLog
|
// applyClublogException overrides a QSO's entity fields from a ClubLog
|
||||||
// exception matching its callsign at its date. force=true ignores the
|
// exception matching its callsign at its date. force=true ignores the
|
||||||
// enable toggle (used by the explicit "Update from ClubLog" action).
|
// enable toggle (used by the explicit "Update from ClubLog" action).
|
||||||
@@ -11966,7 +12361,10 @@ func (a ubAdapter) Status() motorStatus {
|
|||||||
return motorStatus{Connected: st.Connected, Direction: st.Direction, Frequency: st.Frequency, Band: st.Band, Moving: st.MotorsMoving != 0, FreqMin: st.FreqMin, FreqMax: st.FreqMax}
|
return motorStatus{Connected: st.Connected, Direction: st.Direction, Frequency: st.Frequency, Band: st.Band, Moving: st.MotorsMoving != 0, FreqMin: st.FreqMin, FreqMax: st.FreqMax}
|
||||||
}
|
}
|
||||||
|
|
||||||
type steppirAdapter struct{ c *steppir.Client }
|
type steppirAdapter struct {
|
||||||
|
c *steppir.Client
|
||||||
|
freqMin, freqMax int // MHz; tunable range (the SteppIR can't report its own)
|
||||||
|
}
|
||||||
|
|
||||||
func (a steppirAdapter) Start() error { return a.c.Start() }
|
func (a steppirAdapter) Start() error { return a.c.Start() }
|
||||||
func (a steppirAdapter) Stop() { a.c.Stop() }
|
func (a steppirAdapter) Stop() { a.c.Stop() }
|
||||||
@@ -11986,7 +12384,7 @@ func (a steppirAdapter) Status() motorStatus {
|
|||||||
if err != nil || st == nil {
|
if err != nil || st == nil {
|
||||||
return motorStatus{}
|
return motorStatus{}
|
||||||
}
|
}
|
||||||
return motorStatus{Connected: st.Connected, Direction: st.Direction, Frequency: st.Frequency, Band: st.Band, Moving: st.MotorsMoving != 0}
|
return motorStatus{Connected: st.Connected, Direction: st.Direction, Frequency: st.Frequency, Band: st.Band, Moving: st.MotorsMoving != 0, FreqMin: a.freqMin, FreqMax: a.freqMax}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UltrabeamSettings is the JSON shape for the Hardware → Antenna panel. The name
|
// UltrabeamSettings is the JSON shape for the Hardware → Antenna panel. The name
|
||||||
@@ -12002,6 +12400,12 @@ type UltrabeamSettings struct {
|
|||||||
Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency
|
Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency
|
||||||
StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100)
|
StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100)
|
||||||
TXInhibit bool `json:"tx_inhibit"` // block Flex transmission while the elements are moving
|
TXInhibit bool `json:"tx_inhibit"` // block Flex transmission while the elements are moving
|
||||||
|
// SteppIR tunable range (MHz). The follow loop skips frequencies outside it —
|
||||||
|
// no tune command AND no TX inhibit — so a band the antenna can't cover (e.g.
|
||||||
|
// 30 m on a 20 m–6 m SteppIR) never traps TX. Ignored for the Ultrabeam, which
|
||||||
|
// reports its own per-band coverage. 0 = defaults filled in on load for SteppIR.
|
||||||
|
FreqMinMHz int `json:"freq_min_mhz"`
|
||||||
|
FreqMaxMHz int `json:"freq_max_mhz"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUltrabeamSettings returns the persisted motorized-antenna config, defaulting
|
// GetUltrabeamSettings returns the persisted motorized-antenna config, defaulting
|
||||||
@@ -12013,7 +12417,7 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
|
|||||||
return out, fmt.Errorf("db not initialized")
|
return out, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep,
|
m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep,
|
||||||
keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud, keyMotorTXInhibit)
|
keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud, keyMotorTXInhibit, keyMotorFreqMin, keyMotorFreqMax)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -12037,6 +12441,20 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
|
|||||||
if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 {
|
if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 {
|
||||||
out.StepKHz = st
|
out.StepKHz = st
|
||||||
}
|
}
|
||||||
|
out.FreqMinMHz, _ = strconv.Atoi(m[keyMotorFreqMin])
|
||||||
|
out.FreqMaxMHz, _ = strconv.Atoi(m[keyMotorFreqMax])
|
||||||
|
// A SteppIR doesn't report its coverage, so default to the standard 20 m–6 m
|
||||||
|
// range (13–54 MHz) when unset — the common model. Widen it (e.g. min 6 for a
|
||||||
|
// 40 m-equipped SteppIR) in Settings. This is what lets the follow loop leave
|
||||||
|
// TX alone on a band the antenna can't reach.
|
||||||
|
if out.Type == "steppir" {
|
||||||
|
if out.FreqMinMHz <= 0 {
|
||||||
|
out.FreqMinMHz = 13
|
||||||
|
}
|
||||||
|
if out.FreqMaxMHz <= 0 {
|
||||||
|
out.FreqMaxMHz = 54
|
||||||
|
}
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12061,6 +12479,16 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
|
|||||||
if s.Baud < 1200 || s.Baud > 115200 {
|
if s.Baud < 1200 || s.Baud > 115200 {
|
||||||
s.Baud = 9600
|
s.Baud = 9600
|
||||||
}
|
}
|
||||||
|
// Sanity: keep the range ordered; 0 = "no limit" (stored as-is).
|
||||||
|
if s.FreqMinMHz < 0 {
|
||||||
|
s.FreqMinMHz = 0
|
||||||
|
}
|
||||||
|
if s.FreqMaxMHz < 0 {
|
||||||
|
s.FreqMaxMHz = 0
|
||||||
|
}
|
||||||
|
if s.FreqMinMHz > 0 && s.FreqMaxMHz > 0 && s.FreqMinMHz > s.FreqMaxMHz {
|
||||||
|
s.FreqMinMHz, s.FreqMaxMHz = s.FreqMaxMHz, s.FreqMinMHz
|
||||||
|
}
|
||||||
for k, v := range map[string]string{
|
for k, v := range map[string]string{
|
||||||
keyUltrabeamEnabled: boolStr(s.Enabled),
|
keyUltrabeamEnabled: boolStr(s.Enabled),
|
||||||
keyUltrabeamHost: strings.TrimSpace(s.Host),
|
keyUltrabeamHost: strings.TrimSpace(s.Host),
|
||||||
@@ -12072,6 +12500,8 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
|
|||||||
keyMotorCOM: strings.TrimSpace(s.COM),
|
keyMotorCOM: strings.TrimSpace(s.COM),
|
||||||
keyMotorBaud: strconv.Itoa(s.Baud),
|
keyMotorBaud: strconv.Itoa(s.Baud),
|
||||||
keyMotorTXInhibit: boolStr(s.TXInhibit),
|
keyMotorTXInhibit: boolStr(s.TXInhibit),
|
||||||
|
keyMotorFreqMin: strconv.Itoa(s.FreqMinMHz),
|
||||||
|
keyMotorFreqMax: strconv.Itoa(s.FreqMaxMHz),
|
||||||
} {
|
} {
|
||||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -12093,7 +12523,7 @@ func newMotorClient(s UltrabeamSettings) motorAntenna {
|
|||||||
if tr.Mode != "serial" && tr.Host == "" {
|
if tr.Mode != "serial" && tr.Host == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return steppirAdapter{steppir.New(tr)}
|
return steppirAdapter{c: steppir.New(tr), freqMin: s.FreqMinMHz, freqMax: s.FreqMaxMHz}
|
||||||
}
|
}
|
||||||
// Ultrabeam is TCP only.
|
// Ultrabeam is TCP only.
|
||||||
if strings.TrimSpace(s.Host) == "" {
|
if strings.TrimSpace(s.Host) == "" {
|
||||||
@@ -12206,6 +12636,7 @@ func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struc
|
|||||||
ticker := time.NewTicker(1500 * time.Millisecond)
|
ticker := time.NewTicker(1500 * time.Millisecond)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
lastRigKHz := 0 // only log when the followed rig frequency actually changes
|
lastRigKHz := 0 // only log when the followed rig frequency actually changes
|
||||||
|
lastCmdKHz := 0 // rig freq we last issued a move for — the deadband reference
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-stop:
|
case <-stop:
|
||||||
@@ -12239,13 +12670,21 @@ func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struc
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Deadband reference = the antenna's reported freq, or (when it hasn't
|
// Deadband reference = the rig freq we LAST commanded a move for, not the
|
||||||
// reported one yet) the last freq we commanded — so a 0/blank status
|
// antenna's reported freq. A SteppIR reports a flaky/stale status
|
||||||
// doesn't bypass the deadband and re-tune on every small QSY.
|
// frequency (it flips between the commanded freq and its home/6 m value),
|
||||||
ref := st.Frequency
|
// which would trip the deadband and re-issue a SET on almost every poll —
|
||||||
|
// and each SET refreshes the "recently commanded" window that inhibits TX,
|
||||||
|
// leaving the operator permanently unable to transmit. Following the rig
|
||||||
|
// (only re-tune when the RIG actually QSYs beyond the step) is immune to
|
||||||
|
// that. Falls back to the antenna's own freq only until we've commanded once.
|
||||||
|
ref := lastCmdKHz
|
||||||
|
if ref <= 0 {
|
||||||
|
ref = st.Frequency
|
||||||
if ref <= 0 {
|
if ref <= 0 {
|
||||||
ref = c.LastSetKHz()
|
ref = c.LastSetKHz()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
diff := rigKHz - ref
|
diff := rigKHz - ref
|
||||||
if diff < 0 {
|
if diff < 0 {
|
||||||
diff = -diff
|
diff = -diff
|
||||||
@@ -12257,6 +12696,7 @@ func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struc
|
|||||||
if err := c.SetFrequency(rigKHz, st.Direction); err != nil {
|
if err := c.SetFrequency(rigKHz, st.Direction); err != nil {
|
||||||
applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err)
|
applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err)
|
||||||
} else {
|
} else {
|
||||||
|
lastCmdKHz = rigKHz
|
||||||
applog.Printf("ultrabeam: followed rig → %d kHz (dir %d, was ref %d kHz, step %d)", rigKHz, st.Direction, ref, stepKHz)
|
applog.Printf("ultrabeam: followed rig → %d kHz (dir %d, was ref %d kHz, step %d)", rigKHz, st.Direction, ref, stepKHz)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12959,13 +13399,17 @@ func (a *App) GetWinkeyerSettings() (WinkeyerSettings, error) {
|
|||||||
keyWKEnabled, keyWKPort, keyWKBaud, keyWKWPM, keyWKWeight, keyWKLeadIn,
|
keyWKEnabled, keyWKPort, keyWKBaud, keyWKWPM, keyWKWeight, keyWKLeadIn,
|
||||||
keyWKTail, keyWKRatio, keyWKFarnsworth, keyWKSidetone, keyWKMode,
|
keyWKTail, keyWKRatio, keyWKFarnsworth, keyWKSidetone, keyWKMode,
|
||||||
keyWKSwap, keyWKAutoSpace, keyWKUsePTT, keyWKSerialEcho, keyWKMacros,
|
keyWKSwap, keyWKAutoSpace, keyWKUsePTT, keyWKSerialEcho, keyWKMacros,
|
||||||
keyWKEngine, keyWKEscClears, keyWKSendOnType)
|
keyWKEngine, keyWKEscClears, keyWKSendOnType, keyWKCWLine, keyWKCWInvert)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
if v := m[keyWKEngine]; v != "" {
|
if v := m[keyWKEngine]; v != "" {
|
||||||
out.Engine = v
|
out.Engine = v
|
||||||
}
|
}
|
||||||
|
if v := m[keyWKCWLine]; v != "" {
|
||||||
|
out.CWKeyLine = v
|
||||||
|
}
|
||||||
|
out.CWInvert = m[keyWKCWInvert] == "1"
|
||||||
if v := m[keyWKEscClears]; v != "" {
|
if v := m[keyWKEscClears]; v != "" {
|
||||||
out.EscClearsCall = v == "1"
|
out.EscClearsCall = v == "1"
|
||||||
}
|
}
|
||||||
@@ -13032,11 +13476,21 @@ func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error {
|
|||||||
keyWKEngine: strings.TrimSpace(s.Engine),
|
keyWKEngine: strings.TrimSpace(s.Engine),
|
||||||
keyWKEscClears: boolStr(s.EscClearsCall),
|
keyWKEscClears: boolStr(s.EscClearsCall),
|
||||||
keyWKSendOnType: boolStr(s.SendOnType),
|
keyWKSendOnType: boolStr(s.SendOnType),
|
||||||
|
keyWKCWLine: strings.TrimSpace(s.CWKeyLine),
|
||||||
|
keyWKCWInvert: boolStr(s.CWInvert),
|
||||||
} {
|
} {
|
||||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Apply line-control changes (PTT keying, key line, invert, lead/tail, speed)
|
||||||
|
// to a running serial keyer immediately — no reconnect needed. Harmless when
|
||||||
|
// the keyer isn't the serial type or isn't connected.
|
||||||
|
if a.winkeyer != nil && s.Engine == "serial" {
|
||||||
|
cfg := s.Config
|
||||||
|
cfg.Type = "serial"
|
||||||
|
a.winkeyer.ApplySerialConfig(cfg)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13049,7 +13503,13 @@ func (a *App) WinkeyerConnect() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return a.winkeyer.Connect(s.Config)
|
cfg := s.Config
|
||||||
|
// The "serial" engine keys CW on the port's DTR/RTS lines (SCU-17 style)
|
||||||
|
// instead of talking the K1EL WinKeyer protocol — flag it for the manager.
|
||||||
|
if s.Engine == "serial" {
|
||||||
|
cfg.Type = "serial"
|
||||||
|
}
|
||||||
|
return a.winkeyer.Connect(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WinkeyerDisconnect closes the serial link.
|
// WinkeyerDisconnect closes the serial link.
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,4 +1,88 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.21.0",
|
||||||
|
"date": "2026-07-24",
|
||||||
|
"en": [
|
||||||
|
"⚠️ IMPORTANT — ARCHITECTURE CHANGE. OpsLog now keeps your settings/profiles and your QSO logbook in SEPARATE database files (before, everything was in one file). On the first launch after this update, your existing database is split AUTOMATICALLY and non-destructively: your contacts are copied into a new logbook file (logbook.db) while the originals stay untouched in the settings database as a backup — nothing is deleted, no QSO is lost. Existing installs keep their opslog.db as the settings database; fresh installs name it settings.db. As a precaution, back up your OpsLog data folder before updating. Afterwards, Settings → Database shows the settings database and this profile's logbook as two separate sections.",
|
||||||
|
"Your QSOs and your settings now live in separate files: settings + profiles stay in the settings database, while contacts go to a dedicated logbook file (existing logs are migrated automatically, originals kept as a backup). A profile can also point at its own logbook file — ideal for a visiting operator, whose contacts stay out of your log without ever touching your settings or profiles. The Database panel now clearly shows the two, with an 'Open folder' shortcut, and a logbook file can be renamed/relocated (its QSOs move with it).",
|
||||||
|
"Fixed a motorized-antenna bug that could leave a FlexRadio permanently unable to transmit (‘Interlock is preventing transmission’). With a SteppIR whose status frequency reads intermittently (it flipped between the commanded frequency and its home value), the ‘follow the rig’ loop re-sent a tune command on almost every poll, and each command re-armed the ‘block TX while the antenna moves’ window — so the interlock never released. The follow loop now keys its deadband off the rig’s frequency (only re-tuning when the RADIO actually QSYs), immune to a flaky antenna status. Also dropped an ‘interlock set reason=’ command that SmartSDR rejects (it’s read-only) and only produced a harmless error line — the transmit-inhibit itself is unchanged and still holds TX safely while the elements move. New: a SteppIR ‘Tunable range’ setting (Settings → Hardware → Antenna, default 13–54 MHz = 20 m–6 m) — on a band outside it OpsLog leaves the antenna and TX completely alone, so tuning to 30 m on a 20 m–6 m SteppIR no longer tries to move it or touches the interlock.",
|
||||||
|
"New built-in award: The Helvetia 26 Award (H26) — the 26 cantons of Switzerland (USKA), matched from the QSO's address or QTH on HF. Each canton also recognises its main cities (Genève, Lausanne, Zürich, Bellinzona…), since operators rarely write the canton itself.",
|
||||||
|
"FlexRadio panel: the RECEIVE card is shorter again. All the noise controls added recently made it tower, so only the everyday ones — NB, NR, ANF — now stay visible; WNB and the SmartSDR v4 DSP block (NRL/NRS/NRF/ANFL and the AI/FFT RNN & ANFT) tuck behind a 'DSP' button you expand when you need them. The button carries a dot and highlights when one of the hidden controls is switched on, so nothing active is ever out of sight, and its open/closed state is remembered.",
|
||||||
|
"ADIF export can now pick exactly which fields to write. The global 'Export to ADIF' dialog gains a third choice, 'Choose fields…', alongside 'Standard ADIF fields' and 'All OpsLog fields'; and right-clicking selected QSOs adds 'Export selected — choose fields…'. The picker separates the official ADIF 3.1.7 fields (grouped by category) from OpsLog / non-standard tags actually present in your log, with All/None per group and a one-click reset to defaults. Your selection is remembered for next time.",
|
||||||
|
"Fixed 'Incorrect string value' (MySQL error 1366) when a QSO contained non-Latin characters — Cyrillic (Я), Polish ł, etc. — e.g. updating from QRZ. It happened when the shared MySQL database had been pre-created by the hosting panel as latin1, so OpsLog's tables couldn't store those letters. OpsLog now converts the database and its tables to utf8mb4 on connect (once, automatically), and everything stores correctly. Local SQLite logbooks were never affected.",
|
||||||
|
"NET Control: the on-air list now follows a mic-pass order you control instead of log time. A pinned '#' column numbers the round, ⬆⬇ buttons move the selected station up/down the queue, new check-ins join the end, and 'Log & end' advances to the next in line. The order is remembered per net. Header sorting is disabled on this list so a click can't shuffle the round.",
|
||||||
|
"The CW decoder (RX audio → text) is back, with a rebuilt decoding engine. The first version struggled on real signals; the new one adds a windowed tone detector, an adaptive dB envelope with noise squelch, two-way debouncing (a brief fade inside a dash no longer shatters it into dots), per-character dit/dah classification (even the first letter of an over decodes at any speed), and gap-fed speed tracking. It rides QSB, ignores QRM on other pitches, follows 12–40 WPM and sloppy hand keying — all proven by a synthetic-signal test suite. Tools → CW decoder (or the ear button): decodes while the mode is CW, with WPM/pitch/level display, pitch lock (follows the FlexRadio CW pitch automatically), and click-a-word to fill the callsign. Weak signals in heavy QRM remain hard — that's CW Skimmer territory — but clean-to-moderate signals now decode solidly.",
|
||||||
|
"Finished translating the Settings dialog: several panels that had stayed in English are now French too — the CW Keyer panel (and its long serial-engine paragraph removed for clarity), Password encryption, the ClubLog exceptions / Most Wanted blocks in General, and the External services tabs (HRDLOG, eQSL, LoTW, POTA).",
|
||||||
|
"Serial CW keyer: fixed the rig dropping to RX between words during a macro (PTT not held). On USB-serial interfaces like the Yaesu SCU-17 (CP210x), the previous method of driving the DTR/RTS lines would drop the held RTS (PTT) as soon as DTR (CW) toggled. OpsLog now drives the lines with the direct Win32 method N1MM/WSJT use (EscapeCommFunction), so RTS stays asserted for the whole transmission. 'Key PTT line' also defaults on for the Serial engine, and PTT / key-line / speed changes apply immediately without reconnecting the keyer.",
|
||||||
|
"New (Settings → General): show the ClubLog 'Most Wanted' rank in the entry-strip band matrix. When on, a 'MW #rank' pill appears next to the DXCC entity name (1 = the most wanted entity), coloured hotter the more wanted it is. The list is fetched from ClubLog and personalised to your callsign, refreshed daily.",
|
||||||
|
"Fixed the colour theme sometimes resetting to light after an update/relaunch: an update can clear the WebView's localStorage, and the fallback that restores the theme from the local settings database gave up after ~2.4s — occasionally too soon during the brief startup window before that store is ready. It now keeps retrying until the store actually answers, so a dark theme is reliably restored."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"⚠️ IMPORTANT — CHANGEMENT D'ARCHITECTURE. OpsLog stocke désormais tes réglages/profils et ton journal de QSO dans des fichiers de base de données SÉPARÉS (avant, tout était dans un seul fichier). Au premier lancement après cette mise à jour, ta base existante est scindée AUTOMATIQUEMENT et sans destruction : tes contacts sont copiés dans un nouveau fichier journal (logbook.db) tandis que les originaux restent intacts dans la base de réglages en sauvegarde — rien n'est supprimé, aucun QSO n'est perdu. Les installs existantes gardent leur opslog.db comme base de réglages ; les nouvelles installs la nomment settings.db. Par précaution, sauvegarde ton dossier de données OpsLog avant de mettre à jour. Ensuite, Réglages → Base de données affiche la base de réglages et le journal de ce profil en deux sections distinctes.",
|
||||||
|
"Tes QSO et tes réglages sont désormais dans des fichiers séparés : réglages + profils dans la base de réglages, contacts dans un fichier journal dédié (les journaux existants sont migrés automatiquement, les originaux gardés en sauvegarde). Un profil peut aussi pointer vers son propre fichier journal — idéal pour un opérateur de passage, dont les contacts restent hors de ton journal sans jamais toucher tes réglages ni profils. Le panneau Base de données montre maintenant clairement les deux, avec un raccourci « Ouvrir le dossier », et un fichier journal peut être renommé/déplacé (ses QSO le suivent).",
|
||||||
|
"Correction d'un bug d'antenne motorisée qui pouvait laisser un FlexRadio définitivement incapable d'émettre (« Interlock is preventing transmission »). Avec une SteppIR dont la fréquence de statut se lit par intermittence (elle alternait entre la fréquence commandée et sa valeur de repos), la boucle de suivi renvoyait un ordre d'accord à presque chaque cycle, et chaque ordre réarmait la fenêtre « bloquer l'émission pendant que l'antenne bouge » — l'interlock ne se relâchait donc jamais. La boucle de suivi se base maintenant sur la fréquence de la RADIO (elle ne réaccorde que quand le poste change réellement de fréquence), insensible à un statut d'antenne erratique. Retrait aussi d'une commande « interlock set reason= » que SmartSDR refuse (champ en lecture seule) et qui ne produisait qu'une ligne d'erreur sans effet — l'inhibition d'émission elle-même est inchangée et protège toujours pendant le mouvement des éléments. Nouveau : un réglage « Plage accordable » pour la SteppIR (Réglages → Matériel → Antenne, défaut 13-54 MHz = 20 m-6 m) — sur une bande hors de cette plage, OpsLog laisse totalement l'antenne et l'émission tranquilles, donc passer sur 30 m avec une SteppIR 20 m-6 m ne tente plus de la bouger ni ne touche à l'interlock.",
|
||||||
|
"Nouveau diplôme intégré : The Helvetia 26 Award (H26) — les 26 cantons de Suisse (USKA), reconnus depuis l'adresse ou le QTH du QSO en HF. Chaque canton reconnaît aussi ses principales villes (Genève, Lausanne, Zürich, Bellinzone…), car les opérateurs écrivent rarement le canton lui-même.",
|
||||||
|
"Panneau FlexRadio : la carte RÉCEPTION est de nouveau plus compacte. Tous les contrôles de bruit ajoutés récemment la faisaient s'allonger, donc seuls ceux du quotidien — NB, NR, ANF — restent visibles ; le WNB et le bloc DSP SmartSDR v4 (NRL/NRS/NRF/ANFL ainsi que RNN & ANFT IA/FFT) se replient derrière un bouton « DSP » que tu déplies au besoin. Le bouton porte un point et s'illumine quand l'un des contrôles masqués est activé, pour ne jamais perdre de vue quelque chose d'actif, et son état ouvert/fermé est mémorisé.",
|
||||||
|
"L'export ADIF permet maintenant de choisir précisément les champs à écrire. La fenêtre globale « Exporter en ADIF » gagne un troisième choix, « Choisir les champs… », à côté de « Champs ADIF standard » et « Tous les champs OpsLog » ; et le clic droit sur des QSO sélectionnés ajoute « Exporter la sélection — choisir les champs… ». Le sélecteur sépare les champs ADIF 3.1.7 officiels (regroupés par catégorie) des balises OpsLog / non standard réellement présentes dans ton log, avec Tout/Aucun par groupe et un retour aux valeurs par défaut en un clic. Ta sélection est mémorisée pour la prochaine fois.",
|
||||||
|
"Correction de « Incorrect string value » (erreur MySQL 1366) quand un QSO contenait des caractères non latins — cyrillique (Я), polonais ł, etc. — p. ex. lors d'une mise à jour depuis QRZ. Ça arrivait quand la base MySQL partagée avait été pré-créée en latin1 par le panel d'hébergement, empêchant les tables d'OpsLog de stocker ces lettres. OpsLog convertit désormais la base et ses tables en utf8mb4 à la connexion (une seule fois, automatiquement), et tout s'enregistre correctement. Les logbooks SQLite locaux n'étaient pas concernés.",
|
||||||
|
"NET Control : la liste on-air suit maintenant un ordre de passage du micro que tu contrôles, au lieu de l'heure de log. Une colonne « # » épinglée numérote le tour, les boutons ⬆⬇ montent/descendent la station sélectionnée dans la file, les nouveaux arrivants rejoignent la fin, et « Logger & terminer » passe au suivant. L'ordre est mémorisé par NET. Le tri par en-tête est désactivé sur cette liste pour qu'un clic ne bouscule pas le tour.",
|
||||||
|
"Le décodeur CW (audio RX → texte) est de retour, avec un moteur de décodage reconstruit. La première version peinait sur les vrais signaux ; la nouvelle ajoute un détecteur de tonalité fenêtré, une enveloppe dB adaptative avec squelch de bruit, un anti-rebond bilatéral (un bref fading dans un trait ne le brise plus en points), une classification point/trait par caractère (même la première lettre d'un over se décode à n'importe quelle vitesse) et un suivi de vitesse nourri par les espaces. Il tient le QSB, ignore le QRM sur d'autres tonalités, suit de 12 à 40 WPM et la manipulation humaine approximative — le tout prouvé par une suite de tests sur signaux synthétiques. Outils → Décodeur CW (ou le bouton oreille) : décode quand le mode est CW, avec affichage WPM/tonalité/niveau, verrouillage de tonalité (suit automatiquement le pitch CW du FlexRadio) et clic-sur-un-mot pour remplir l'indicatif. Les signaux faibles dans un fort QRM restent difficiles — c'est le territoire de CW Skimmer — mais les signaux propres à moyens se décodent maintenant solidement.",
|
||||||
|
"Fin de la traduction de la fenêtre Réglages : plusieurs panneaux restés en anglais sont maintenant en français aussi — le Manipulateur CW (et son long paragraphe moteur série retiré pour l'alléger), le Chiffrement des mots de passe, les blocs Exceptions ClubLog / Most Wanted dans Général, et les onglets Services externes (HRDLOG, eQSL, LoTW, POTA).",
|
||||||
|
"Keyer CW série : correction de la radio qui retombait en RX entre les mots pendant une macro (PTT non tenu). Sur les interfaces USB-série comme le Yaesu SCU-17 (CP210x), l'ancienne méthode de pilotage des lignes DTR/RTS faisait retomber le RTS (PTT) dès que DTR (CW) basculait. OpsLog pilote maintenant les lignes avec la méthode Win32 directe qu'utilisent N1MM/WSJT (EscapeCommFunction), donc RTS reste asserté toute l'émission. « Key PTT line » est aussi activé par défaut pour le moteur Série, et les changements PTT / ligne / vitesse s'appliquent immédiatement sans reconnecter le keyer.",
|
||||||
|
"Nouveau (Réglages → Général) : afficher le rang « Most Wanted » de ClubLog dans la matrice de bandes de la barre de saisie. Activé, une pastille « MW #rang » apparaît à côté du nom de l'entité DXCC (1 = l'entité la plus recherchée), d'autant plus colorée qu'elle est recherchée. La liste est récupérée depuis ClubLog et personnalisée selon ton indicatif, rafraîchie quotidiennement.",
|
||||||
|
"Correction du thème de couleur qui revenait parfois au clair après une mise à jour/relancement : une mise à jour peut vider le localStorage de la WebView, et le repli qui restaure le thème depuis la base de réglages locale abandonnait après ~2,4s — parfois trop tôt pendant le court instant de démarrage avant que ce store soit prêt. Il réessaie maintenant jusqu'à ce que le store réponde vraiment, donc un thème sombre est restauré de façon fiable."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.20.11",
|
||||||
|
"date": "2026-07-23",
|
||||||
|
"en": [
|
||||||
|
"Fixed clicking a spot / band-map entry not moving the radio on Yaesu (and other non-Icom) rigs controlled through OmniRig — e.g. the FT-891. OpsLog tuned via OmniRig's SetSimplexMode, which on these rigs returns success but silently doesn't move the VFO (mode changes worked, so it looked like a puzzle). It now also writes the VFO frequency property directly, which does move them. Icom rigs are unchanged (there SetSimplexMode is the reliable path).",
|
||||||
|
"New CW keyer engine: 'Serial port (DTR=CW / RTS=PTT)'. OpsLog can now key CW by toggling a serial control line itself — the hardware-keying method N1MM/WSJT use with a Yaesu SCU-17 and generic keying interfaces — without a K1EL WinKeyer. Settings → CW Keyer → Keyer engine: pick 'Serial port', choose the keying COM port and whether CW is on DTR (PTT on RTS) or swapped. Speed, Farnsworth, lead-in/tail and PTT keying all apply; macros, auto-CQ and <LOGQSO> work exactly as with the WinKeyer. An 'Invert keying (active-LOW)' option is there for interfaces that key backwards / transmit at rest.",
|
||||||
|
"Fixed the spectrum scope never displaying on the IC-7300: its CI-V waveform frames actually carry the same leading main-scope selector byte as the dual-scope IC-7610/9700, but OpsLog assumed the 7300 omitted it — so it read the frame sequence number from the wrong byte (always 0), discarded every frame, and drew nothing. The frame layout is now detected from the data itself, so the 7300 (and other single-scope Icoms) render correctly, in both center and fixed modes. The IC-7610/9700 are unaffected.",
|
||||||
|
"IC-7300 scope: the center/fixed mode and edge-frequency commands now include the selector byte the rig requires, so switching the scope between center-on-VFO and fixed span from OpsLog is accepted instead of being rejected.",
|
||||||
|
"Closing OpsLog no longer switches off the scope display on the radio itself: turning the scope off used to send both 'stop CI-V data' and 'display off', so quitting blanked a local IC-7300's own scope screen. It now only stops the CI-V data stream on disable and never touches the radio's display (the display-on command is sent solely when enabling)."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Correction : cliquer un spot / une entrée de band-map ne changeait pas la fréquence sur les Yaesu (et autres radios non-Icom) pilotées via OmniRig — p. ex. le FT-891. OpsLog accordait via SetSimplexMode d'OmniRig, qui sur ces radios renvoie « OK » mais ne bouge pas le VFO (le changement de mode marchait, d'où l'énigme). Il écrit maintenant aussi directement la propriété fréquence du VFO, ce qui les fait bien QSY. Les Icom sont inchangés (là, SetSimplexMode reste la bonne méthode).",
|
||||||
|
"Nouveau moteur de keyer CW : « Port série (DTR=CW / RTS=PTT) ». OpsLog peut désormais manipuler la CW en basculant lui-même une ligne de contrôle série — la méthode de keying matériel qu'utilisent N1MM/WSJT avec un Yaesu SCU-17 et les interfaces génériques — sans WinKeyer K1EL. Réglages → Keyer CW → Moteur : choisis « Port série », le port COM de keying et si la CW est sur DTR (PTT sur RTS) ou l'inverse. Vitesse, Farnsworth, lead-in/tail et PTT s'appliquent ; les macros, l'auto-CQ et <LOGQSO> fonctionnent exactement comme avec le WinKeyer. Une option « Inverser le keying (actif-BAS) » est prévue pour les interfaces qui manipulent à l'envers / émettent au repos.",
|
||||||
|
"Correction du scope spectral qui ne s'affichait jamais sur l'IC-7300 : ses trames de forme d'onde CI-V portent en réalité le même octet sélecteur de scope en tête que les IC-7610/9700 (dual-scope), mais OpsLog supposait que le 7300 ne l'envoyait pas — il lisait donc le numéro de séquence de la trame sur le mauvais octet (toujours 0), jetait toutes les trames et ne dessinait rien. La disposition des trames est maintenant détectée à partir des données elles-mêmes, donc le 7300 (et les autres Icom mono-scope) s'affichent correctement, en mode centre comme en fixe. Les IC-7610/9700 ne sont pas affectés.",
|
||||||
|
"Scope IC-7300 : les commandes de mode centre/fixe et de fréquences de bords incluent maintenant l'octet sélecteur requis par la radio, donc basculer le scope entre centre-sur-VFO et span fixe depuis OpsLog est accepté au lieu d'être rejeté.",
|
||||||
|
"Fermer OpsLog n'éteint plus le scope sur la radio elle-même : couper le scope envoyait à la fois « stop données CI-V » et « affichage OFF », donc quitter éteignait l'écran scope d'un IC-7300 local. Désormais seule la diffusion des données CI-V est coupée à la désactivation, l'affichage de la radio n'est jamais touché (la commande d'allumage n'est envoyée qu'à l'activation)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.20.10",
|
||||||
|
"date": "2026-07-23",
|
||||||
|
"en": [
|
||||||
|
"Clicking an OpsLog spot on the FlexRadio panadapter now switches the mode too, not just the frequency: cluster spots (which carry no mode) get one inferred from the comment or the band plan, and every spot's mode is sent to the radio as a real SmartSDR mode (SSB resolves to USB/LSB by frequency), so the click tunes AND sets the mode.",
|
||||||
|
"Station Control is now a clean dashboard of fixed-width panels that wrap to fill the window. Each panel has a grip handle on its left edge — drag it to move the panel anywhere, and the order is remembered. Relay buttons are compact. The amplifier panel is now the exact same card as the FlexRadio panel's (OPERATE/STANDBY, ON/OFF, power level, output-power bar and live FWD/drain-current/temperature meters), and if you run several amplifiers they all appear — one card each, no dropdown.",
|
||||||
|
"Station Control: the Ultrabeam element list now shows the right number of elements (3 for a 3-element beam) instead of extra bogus rows that came from over-reading the controller's reply.",
|
||||||
|
"Fixed the FlexRadio amplifier meters showing twice after the amp was power-cycled — the old (stale) meters are now cleared when the amplifier is torn down, so only the fresh set remains.",
|
||||||
|
"Fixed QSO audio recordings silently not being saved: the recording snapshot had moved onto the background (post-log) path, where it raced the entry-form clearing that cancels the recorder — so the audio was discarded before it was captured. The snapshot is now taken the instant you log, before the form clears; the file encoding still happens in the background.",
|
||||||
|
"Digital Voice Keyer: an Auto CQ (like the CW keyer's auto-call) repeats a CQ-labelled voice message on a timer until you stop it, play another slot or press ESC. The DVK now also refuses to transmit on non-phone modes (CW/FT8/RTTY…) — the buttons grey out and it won't key the rig with speech on a data mode.",
|
||||||
|
"The Digital Voice Keyer (DVK) panel now stays open across a relaunch if you had it open — it used to reset to closed every time.",
|
||||||
|
"FlexRadio meter scales now match SmartSDR: MIC (level) tops out at 0 dB and COMP (compression) tops out at -25 dB (was -20).",
|
||||||
|
"New Help → 'Send log to F4BPO' (shown only when you have an SMTP server configured in Settings → E-mail): e-mails OpsLog's diagnostic logs (current session, previous session and any crash log) straight to the developer, so a problem you hit in the field can be looked at without hunting for the log files by hand.",
|
||||||
|
"Bulk edit can now set the frequency: pick 'Frequency (MHz)', enter e.g. 7.155, and every selected QSO gets that frequency with its band recomputed to match. Handy for fixing a run that was logged on a stale/default frequency (e.g. 7.000) after CAT control dropped. Out-of-band values are rejected so a typo can't corrupt the batch.",
|
||||||
|
"Fixed the Cluster spot list appearing to 'freeze' at the UTC midnight rollover: the Time column was sorted as plain text, so a spot at 0001Z sorted below 2359Z and new spots after 0000Z dropped to the bottom of the list instead of the top — it looked like the cluster had stopped. The column now sorts by actual arrival time, which crosses midnight correctly.",
|
||||||
|
"Fixed the spectrum scope being blank on the IC-7300 (and other single-scope Icoms) when the scope was in center / VFO-following mode: the multi-frame waveform header read the second frequency field as an absolute high edge, but in center mode it is a span, so the display got an invalid range and drew nothing. It now interprets center+span vs low+high edges the same way the IC-7610 path already did. FIXED mode was unaffected."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Cliquer sur un spot OpsLog du panadapter FlexRadio change maintenant aussi le mode, plus seulement la fréquence : les spots cluster (qui n'ont pas de mode) en reçoivent un déduit du commentaire ou du plan de bande, et le mode de chaque spot est envoyé à la radio comme un vrai mode SmartSDR (SSB devient USB/LSB selon la fréquence), donc le clic accorde ET règle le mode.",
|
||||||
|
"Station Control est maintenant un tableau de bord de panneaux à largeur fixe qui s'alignent pour remplir la fenêtre. Chaque panneau a une poignée sur son bord gauche — glisse-la pour déplacer le panneau où tu veux, et l'ordre est mémorisé. Les boutons relais sont compacts. Le panneau amplificateur est désormais exactement la même carte que celle du panneau FlexRadio (OPERATE/STANDBY, ON/OFF, niveau de puissance, barre de puissance de sortie et mesures en direct puissance directe/courant de drain/température), et si tu utilises plusieurs amplis ils apparaissent tous — une carte chacun, sans liste déroulante.",
|
||||||
|
"Station Control : la liste des éléments Ultrabeam affiche maintenant le bon nombre d'éléments (3 pour une beam 3 éléments) au lieu de lignes fantômes issues d'une sur-lecture de la réponse du contrôleur.",
|
||||||
|
"Correction des mesures de l'amplificateur FlexRadio affichées en double après un cycle d'alimentation de l'ampli — les anciennes mesures (périmées) sont maintenant effacées quand l'amplificateur est retiré, seul le jeu à jour reste.",
|
||||||
|
"Correction des enregistrements audio des QSO qui n'étaient plus sauvegardés : la capture de l'enregistrement était passée sur le chemin d'arrière-plan (après le log), où elle entrait en concurrence avec le vidage du formulaire qui annule l'enregistreur — l'audio était donc jeté avant d'être capturé. La capture se fait maintenant à l'instant du log, avant le vidage du formulaire ; l'encodage du fichier reste en arrière-plan.",
|
||||||
|
"Manipulateur vocal (DVK) : un Auto CQ (comme l'auto-call du keyer CW) répète un message vocal libellé CQ à intervalle régulier jusqu'à l'arrêt, la lecture d'un autre slot ou ÉCHAP. Le DVK refuse aussi désormais d'émettre hors phonie (CW/FT8/RTTY…) — les boutons sont grisés et il ne manipule plus la radio avec de la voix sur un mode data.",
|
||||||
|
"Le panneau Digital Voice Keyer (DVK) reste maintenant ouvert après un relancement si vous l'aviez ouvert — il se refermait à chaque démarrage.",
|
||||||
|
"Les échelles des meters FlexRadio collent maintenant à SmartSDR : MIC (niveau) plafonne à 0 dB et COMP (compression) à -25 dB (au lieu de -20).",
|
||||||
|
"Nouveau Aide → « Envoyer le journal à F4BPO » (affiché seulement si un serveur SMTP est configuré dans Réglages → E-mail) : envoie par e-mail les journaux de diagnostic d'OpsLog (session en cours, session précédente et éventuel journal de crash) directement au développeur, pour qu'un problème rencontré sur le terrain puisse être examiné sans avoir à chercher les fichiers de journal à la main.",
|
||||||
|
"L'édition groupée permet maintenant de définir la fréquence : choisis « Fréquence (MHz) », saisis p. ex. 7.155, et chaque QSO sélectionné reçoit cette fréquence avec sa bande recalculée en conséquence. Pratique pour corriger une série loguée sur une fréquence par défaut/figée (p. ex. 7.000) après une perte du CAT. Les valeurs hors bande sont refusées pour qu'une faute de frappe ne corrompe pas le lot.",
|
||||||
|
"Correction de la liste des spots Cluster qui semblait « se figer » au passage de minuit UTC : la colonne Heure était triée comme du texte, donc un spot à 0001Z se retrouvait sous 2359Z et les nouveaux spots après 0000Z tombaient en bas de la liste au lieu du haut — on aurait dit que le cluster s'était arrêté. La colonne trie désormais selon l'heure d'arrivée réelle, qui franchit minuit correctement.",
|
||||||
|
"Correction du scope spectral vide sur l'IC-7300 (et autres Icom mono-scope) quand le scope était en mode centre / suivi du VFO : l'en-tête de forme d'onde multi-trames lisait le second champ de fréquence comme un bord haut absolu, alors qu'en mode centre c'est un span — l'affichage recevait donc une plage invalide et ne dessinait rien. Il interprète maintenant centre+span vs bords bas+haut comme le faisait déjà le chemin de l'IC-7610. Le mode FIXED n'était pas affecté."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.20.9",
|
"version": "0.20.9",
|
||||||
"date": "2026-07-22",
|
"date": "2026-07-22",
|
||||||
|
|||||||
+247
-32
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Eraser, Hash, Loader2, Lock,
|
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
||||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
|
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||||
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
|
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
|
||||||
|
SMTPConfigured, SendLogToDeveloper,
|
||||||
WorkedBefore,
|
WorkedBefore,
|
||||||
SetCompactMode,
|
SetCompactMode,
|
||||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
||||||
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
|
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
|
||||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||||
|
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||||
GetAwardDefs,
|
GetAwardDefs,
|
||||||
@@ -72,6 +74,7 @@ import { AwardsPanel } from '@/components/AwardsPanel';
|
|||||||
import { StatsPanel } from '@/components/StatsPanel';
|
import { StatsPanel } from '@/components/StatsPanel';
|
||||||
import { StationControlPanel } from '@/components/StationControlPanel';
|
import { StationControlPanel } from '@/components/StationControlPanel';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
|
import { ExportFieldsDialog } from '@/components/ExportFieldsDialog';
|
||||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||||
@@ -851,6 +854,37 @@ export default function App() {
|
|||||||
|
|
||||||
// === Digital Voice Keyer (DVK) ===
|
// === Digital Voice Keyer (DVK) ===
|
||||||
|
|
||||||
|
// CW decoder: taps RX audio and decodes Morse. Runs only when enabled AND the
|
||||||
|
// mode is CW. The decoded text appears in a strip above the tabs.
|
||||||
|
const [cwEnabled, setCwEnabled] = useState(() => localStorage.getItem('opslog.cwDecoder') === '1');
|
||||||
|
const [cwText, setCwText] = useState('');
|
||||||
|
const [cwStatus, setCwStatus] = useState<{ wpm: number; pitch: number; level: number; active: boolean }>({ wpm: 0, pitch: 0, level: 0, active: false });
|
||||||
|
const cwOn = cwEnabled && mode === 'CW';
|
||||||
|
// Keep the decoded line scrolled to the newest text (left-aligned, no scrollbar).
|
||||||
|
const cwScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => { const el = cwScrollRef.current; if (el) el.scrollLeft = el.scrollWidth; }, [cwText]);
|
||||||
|
// Manual pitch override ('' = Auto: follow the radio's CW pitch / search).
|
||||||
|
const [cwPitch, setCwPitch] = useState(() => localStorage.getItem('opslog.cwPitch') || '');
|
||||||
|
useEffect(() => {
|
||||||
|
const hz = parseInt(cwPitch, 10);
|
||||||
|
SetCWDecoderPitch(Number.isFinite(hz) ? hz : 0).catch(() => {});
|
||||||
|
localStorage.setItem('opslog.cwPitch', cwPitch);
|
||||||
|
}, [cwPitch, cwOn]);
|
||||||
|
useEffect(() => {
|
||||||
|
const offT = EventsOn('cw:text', (txt: string) => setCwText((s) => (s + txt).slice(-200)));
|
||||||
|
const offS = EventsOn('cw:status', (st: any) => setCwStatus(st));
|
||||||
|
const offE = EventsOn('cw:error', (e: string) => { setError(String(e)); setCwEnabled(false); });
|
||||||
|
return () => { offT?.(); offS?.(); offE?.(); };
|
||||||
|
}, []);
|
||||||
|
// Start/stop the backend decoder as the (enabled, mode) combination changes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (cwOn) { StartCWDecoder().catch((e: any) => { setError(String(e?.message ?? e)); setCwEnabled(false); }); }
|
||||||
|
else { StopCWDecoder().catch(() => {}); }
|
||||||
|
}, [cwOn]);
|
||||||
|
function toggleCwDecoder() {
|
||||||
|
setCwEnabled((v) => { const n = !v; localStorage.setItem('opslog.cwDecoder', n ? '1' : '0'); return n; });
|
||||||
|
}
|
||||||
|
|
||||||
// === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK ===
|
// === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK ===
|
||||||
const [chatAvailable, setChatAvailable] = useState(false);
|
const [chatAvailable, setChatAvailable] = useState(false);
|
||||||
const [chatOpen, setChatOpen] = useState(false);
|
const [chatOpen, setChatOpen] = useState(false);
|
||||||
@@ -950,14 +984,19 @@ export default function App() {
|
|||||||
const [contestTabEnabled, setContestTabEnabled] = useState(() => localStorage.getItem('opslog.contestTab') === '1');
|
const [contestTabEnabled, setContestTabEnabled] = useState(() => localStorage.getItem('opslog.contestTab') === '1');
|
||||||
useEffect(() => { localStorage.setItem('opslog.contestTab', contestTabEnabled ? '1' : '0'); }, [contestTabEnabled]);
|
useEffect(() => { localStorage.setItem('opslog.contestTab', contestTabEnabled ? '1' : '0'); }, [contestTabEnabled]);
|
||||||
|
|
||||||
const [dvkEnabled, setDvkEnabled] = useState(false);
|
// DVK panel open/closed — persisted like the other Tools toggles above so it
|
||||||
|
// survives a relaunch instead of resetting to closed.
|
||||||
|
const [dvkEnabled, setDvkEnabled] = useState(() => localStorage.getItem('opslog.dvkEnabled') === '1');
|
||||||
|
useEffect(() => { localStorage.setItem('opslog.dvkEnabled', dvkEnabled ? '1' : '0'); }, [dvkEnabled]);
|
||||||
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||||||
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||||||
const dvkActiveRef = useRef(false);
|
const dvkActiveRef = useRef(false);
|
||||||
const dvkPlayingRef = useRef(false);
|
const dvkPlayingRef = useRef(false);
|
||||||
const dvkPlayRef = useRef<(slot: number) => void>(() => {});
|
const dvkPlayRef = useRef<(slot: number) => void>(() => {});
|
||||||
|
const dvkMsgsRef = useRef<DVKMsg[]>([]);
|
||||||
useEffect(() => { dvkActiveRef.current = dvkEnabled; }, [dvkEnabled]);
|
useEffect(() => { dvkActiveRef.current = dvkEnabled; }, [dvkEnabled]);
|
||||||
useEffect(() => { dvkPlayingRef.current = dvkStat.playing; }, [dvkStat.playing]);
|
useEffect(() => { dvkPlayingRef.current = dvkStat.playing; }, [dvkStat.playing]);
|
||||||
|
useEffect(() => { dvkMsgsRef.current = dvkMsgs; }, [dvkMsgs]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat));
|
const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat));
|
||||||
return () => { off?.(); };
|
return () => { off?.(); };
|
||||||
@@ -969,7 +1008,48 @@ export default function App() {
|
|||||||
reloadDvk();
|
reloadDvk();
|
||||||
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
|
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
|
||||||
}, [dvkEnabled, reloadDvk]);
|
}, [dvkEnabled, reloadDvk]);
|
||||||
const dvkPlay = useCallback((slot: number) => { DVKPlay(slot).catch((e: any) => setError(String(e?.message ?? e))); }, []);
|
// DVK auto-CQ: repeat a CQ voice message on a timer, like the CW keyer's
|
||||||
|
// auto-call. Only slots whose LABEL contains "CQ" loop; any other slot plays
|
||||||
|
// once (and stops a running loop), so a report doesn't keep repeating.
|
||||||
|
const [dvkAutoCq, setDvkAutoCq] = useState(() => localStorage.getItem('opslog.dvkAutoCq') === '1');
|
||||||
|
const [dvkAutoCqSecs, setDvkAutoCqSecs] = useState(() => Number(localStorage.getItem('opslog.dvkAutoCqSecs')) || 3);
|
||||||
|
const dvkAutoCqRef = useRef(dvkAutoCq);
|
||||||
|
const dvkAutoCqSecsRef = useRef(dvkAutoCqSecs);
|
||||||
|
const dvkAutoCqGenRef = useRef(0);
|
||||||
|
const dvkAutoCqSlotRef = useRef(-1);
|
||||||
|
useEffect(() => { dvkAutoCqRef.current = dvkAutoCq; localStorage.setItem('opslog.dvkAutoCq', dvkAutoCq ? '1' : '0'); }, [dvkAutoCq]);
|
||||||
|
useEffect(() => { dvkAutoCqSecsRef.current = dvkAutoCqSecs; localStorage.setItem('opslog.dvkAutoCqSecs', String(dvkAutoCqSecs)); }, [dvkAutoCqSecs]);
|
||||||
|
// The DVK is a VOICE keyer — transmitting it on CW/FT8/RTTY would key the rig
|
||||||
|
// with speech on a data slot. Only allow it on phone modes.
|
||||||
|
const isPhoneMode = (m: string) => RECORDABLE_MODES.has((m || '').toUpperCase());
|
||||||
|
const modeRef = useRef(mode);
|
||||||
|
useEffect(() => { modeRef.current = mode; }, [mode]);
|
||||||
|
function stopDvkAutoCq() { dvkAutoCqSlotRef.current = -1; dvkAutoCqGenRef.current++; }
|
||||||
|
async function runDvkAutoCq(slot: number) {
|
||||||
|
const gen = ++dvkAutoCqGenRef.current;
|
||||||
|
dvkAutoCqSlotRef.current = slot;
|
||||||
|
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
|
||||||
|
while (dvkAutoCqSlotRef.current === slot && gen === dvkAutoCqGenRef.current && dvkActiveRef.current) {
|
||||||
|
if (!isPhoneMode(modeRef.current)) { stopDvkAutoCq(); break; }
|
||||||
|
await DVKPlay(slot).catch(() => {});
|
||||||
|
await sleep(300); // let playback flip "playing" true
|
||||||
|
let guard = 0; // then wait for it to finish (cap ~90 s)
|
||||||
|
while (dvkPlayingRef.current && gen === dvkAutoCqGenRef.current && guard < 600) { await sleep(150); guard++; }
|
||||||
|
if (gen !== dvkAutoCqGenRef.current) break;
|
||||||
|
await sleep(Math.max(0, dvkAutoCqSecsRef.current) * 1000); // gap before the next CQ
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dvkPlay = useCallback((slot: number) => {
|
||||||
|
if (!isPhoneMode(modeRef.current)) { setError(t('dvkp.notPhone')); return; }
|
||||||
|
const m = dvkMsgsRef.current.find((x) => x.slot === slot);
|
||||||
|
const isCQ = (m?.label || '').toUpperCase().includes('CQ');
|
||||||
|
if (dvkAutoCqRef.current && isCQ) {
|
||||||
|
runDvkAutoCq(slot); // loop this CQ until Stop / a non-CQ slot / mode change
|
||||||
|
} else {
|
||||||
|
stopDvkAutoCq();
|
||||||
|
DVKPlay(slot).catch((e: any) => setError(String(e?.message ?? e)));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
useEffect(() => { dvkPlayRef.current = dvkPlay; }, [dvkPlay]);
|
useEffect(() => { dvkPlayRef.current = dvkPlay; }, [dvkPlay]);
|
||||||
// Controlled active tab of the F1-F5 detail panel (so Ctrl+F1-F5 can switch
|
// Controlled active tab of the F1-F5 detail panel (so Ctrl+F1-F5 can switch
|
||||||
// it from the keyboard without clashing with the F1-F12 keyer macros).
|
// it from the keyboard without clashing with the F1-F12 keyer macros).
|
||||||
@@ -1187,6 +1267,15 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
GetWhatsNew().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); }).catch(() => {});
|
GetWhatsNew().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); }).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
// Whether an SMTP server is configured — gates the "Send log to F4BPO" help
|
||||||
|
// entry. Re-checked when the Settings modal closes (the user may have just set
|
||||||
|
// it up), so the entry appears without a restart.
|
||||||
|
const [smtpConfigured, setSmtpConfigured] = useState(false);
|
||||||
|
const [sendingLog, setSendingLog] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSettings) return; // re-check after closing Settings
|
||||||
|
SMTPConfigured().then((v: any) => setSmtpConfigured(!!v)).catch(() => {});
|
||||||
|
}, [showSettings]);
|
||||||
const [showDuplicates, setShowDuplicates] = useState(false);
|
const [showDuplicates, setShowDuplicates] = useState(false);
|
||||||
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
|
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
|
||||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||||
@@ -1240,6 +1329,9 @@ export default function App() {
|
|||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
// Export mode chooser: standard ADIF (other loggers) vs full (OpsLog round-trip).
|
// Export mode chooser: standard ADIF (other loggers) vs full (OpsLog round-trip).
|
||||||
const [showExportChoice, setShowExportChoice] = useState(false);
|
const [showExportChoice, setShowExportChoice] = useState(false);
|
||||||
|
// Field-picker export: null when closed; ids present = export those selected
|
||||||
|
// rows, absent = export the whole logbook (global "choose fields" flow).
|
||||||
|
const [fieldPicker, setFieldPicker] = useState<{ ids?: number[]; count: number } | null>(null);
|
||||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||||
const [importErrorsOpen, setImportErrorsOpen] = useState(false);
|
const [importErrorsOpen, setImportErrorsOpen] = useState(false);
|
||||||
const [importDupsOpen, setImportDupsOpen] = useState(false);
|
const [importDupsOpen, setImportDupsOpen] = useState(false);
|
||||||
@@ -2074,7 +2166,7 @@ export default function App() {
|
|||||||
setWkMacros((s.macros ?? []) as WKMacro[]);
|
setWkMacros((s.macros ?? []) as WKMacro[]);
|
||||||
setWkEscClears(s.esc_clears_call !== false);
|
setWkEscClears(s.esc_clears_call !== false);
|
||||||
setWkSendOnType(!!s.send_on_type);
|
setWkSendOnType(!!s.send_on_type);
|
||||||
setWkEngine(s.engine === 'icom' ? 'icom' : s.engine === 'flex' ? 'flex' : 'winkeyer');
|
setWkEngine(s.engine === 'icom' ? 'icom' : s.engine === 'flex' ? 'flex' : s.engine === 'serial' ? 'serial' : 'winkeyer');
|
||||||
} catch { /* keyer not configured */ }
|
} catch { /* keyer not configured */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -2526,25 +2618,32 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
// Right-click "Export filtered to ADIF (no limit)": exports every QSO that
|
// Right-click "Export filtered to ADIF (no limit)": exports every QSO that
|
||||||
// matches the current filter, bypassing the on-screen row threshold.
|
// matches the current filter, bypassing the on-screen row threshold.
|
||||||
async function exportFilteredADIF() {
|
// fields (optional): a chosen ADIF-tag list — the "choose which fields" export.
|
||||||
|
// Empty = the standard/full behaviour (all fields, includeAppFields=false here).
|
||||||
|
async function exportFilteredADIF(fields: string[] = []) {
|
||||||
try {
|
try {
|
||||||
const path = await SaveADIFFile();
|
const path = await SaveADIFFile();
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
const f = buildActiveFilter();
|
const f = buildActiveFilter();
|
||||||
const r = await ExportADIFFiltered(path, false, { ...f, limit: 0, offset: 0 } as any);
|
const r = await ExportADIFFiltered(path, false, { ...f, limit: 0, offset: 0 } as any, fields);
|
||||||
showToast(`Exported ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
showToast(`Exported ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
||||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
// Right-click "Export selected to ADIF": only the highlighted rows.
|
// Right-click "Export selected to ADIF": only the highlighted rows.
|
||||||
async function exportSelectedADIF(ids: number[]) {
|
async function exportSelectedADIF(ids: number[], fields: string[] = []) {
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
try {
|
try {
|
||||||
const path = await SaveADIFFile();
|
const path = await SaveADIFFile();
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
const r = await ExportADIFSelected(path, false, ids as any);
|
const r = await ExportADIFSelected(path, false, ids as any, fields);
|
||||||
showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
||||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
// Open the field-picker to export the highlighted rows with chosen fields.
|
||||||
|
function exportSelectedFields(ids: number[]) {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
setFieldPicker({ ids, count: ids.length });
|
||||||
|
}
|
||||||
// Cabrillo (contest log) export → a .log file. The contest name comes from the
|
// Cabrillo (contest log) export → a .log file. The contest name comes from the
|
||||||
// QSOs' ADIF CONTEST_ID (no prompt). All / filtered / selected mirror the ADIF ones.
|
// QSOs' ADIF CONTEST_ID (no prompt). All / filtered / selected mirror the ADIF ones.
|
||||||
async function exportCabrillo() {
|
async function exportCabrillo() {
|
||||||
@@ -2784,7 +2883,10 @@ export default function App() {
|
|||||||
if (exporting) return;
|
if (exporting) return;
|
||||||
setShowExportChoice(true); // pick standard vs full first
|
setShowExportChoice(true); // pick standard vs full first
|
||||||
}
|
}
|
||||||
async function runExport(includeAppFields: boolean) {
|
// includeAppFields is ignored when fields is a non-empty chosen set (the set
|
||||||
|
// decides everything). Used by both the standard/full buttons and the
|
||||||
|
// "choose fields" flow (global export of the whole logbook).
|
||||||
|
async function runExport(includeAppFields: boolean, fields: string[] = []) {
|
||||||
setShowExportChoice(false);
|
setShowExportChoice(false);
|
||||||
if (exporting) return;
|
if (exporting) return;
|
||||||
setError('');
|
setError('');
|
||||||
@@ -2792,9 +2894,10 @@ export default function App() {
|
|||||||
const path = await SaveADIFFile();
|
const path = await SaveADIFFile();
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
const res = await ExportADIF(path, includeAppFields);
|
const res = await ExportADIF(path, includeAppFields, fields);
|
||||||
|
const kind = fields.length ? ` (${fields.length} fields)` : includeAppFields ? ' (full)' : ' (standard)';
|
||||||
// Green success toast (auto-dismiss) — not the red error banner.
|
// Green success toast (auto-dismiss) — not the red error banner.
|
||||||
showToast(`ADIF exported${includeAppFields ? ' (full)' : ' (standard)'}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`);
|
showToast(`ADIF exported${kind}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(`ADIF export failed: ${String(e?.message ?? e)}`);
|
setError(`ADIF export failed: ${String(e?.message ?? e)}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2852,6 +2955,7 @@ export default function App() {
|
|||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
||||||
{ type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' },
|
{ type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' },
|
||||||
|
{ type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
|
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
|
||||||
{ type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' },
|
{ type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' },
|
||||||
@@ -2866,9 +2970,15 @@ export default function App() {
|
|||||||
]},
|
]},
|
||||||
{ name: 'help', label: t('menu.help'), items: [
|
{ name: 'help', label: t('menu.help'), items: [
|
||||||
{ type: 'item', label: t('whatsnew.title'), action: 'help.whatsnew' },
|
{ type: 'item', label: t('whatsnew.title'), action: 'help.whatsnew' },
|
||||||
|
// Only when SMTP is set up: e-mail the diagnostic logs to the developer.
|
||||||
|
...(smtpConfigured ? [
|
||||||
|
{ type: 'separator' as const },
|
||||||
|
{ type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
|
||||||
|
] : []),
|
||||||
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
||||||
]},
|
]},
|
||||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, netEnabled, contestTabEnabled, t]);
|
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
|
||||||
|
|
||||||
function handleMenu(action: string) {
|
function handleMenu(action: string) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -2889,6 +2999,7 @@ export default function App() {
|
|||||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||||
|
case 'tools.cwdecoder': toggleCwDecoder(); break;
|
||||||
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
|
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
|
||||||
case 'tools.contest': setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break;
|
case 'tools.contest': setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break;
|
||||||
case 'tools.alerts': setAlertsOpen(true); break;
|
case 'tools.alerts': setAlertsOpen(true); break;
|
||||||
@@ -2897,6 +3008,21 @@ export default function App() {
|
|||||||
case 'tools.downloadRefs': downloadRefs(); break;
|
case 'tools.downloadRefs': downloadRefs(); break;
|
||||||
case 'help.about': setShowAbout(true); checkUpdateNow(); break;
|
case 'help.about': setShowAbout(true); checkUpdateNow(); break;
|
||||||
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
|
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
|
||||||
|
case 'help.sendlog': sendLogToDeveloper(); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// E-mail the diagnostic logs to the developer (Help → Send log to F4BPO).
|
||||||
|
async function sendLogToDeveloper() {
|
||||||
|
if (sendingLog) return;
|
||||||
|
setSendingLog(true);
|
||||||
|
try {
|
||||||
|
await SendLogToDeveloper();
|
||||||
|
showToast(t('help.sendLogOk'));
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast(t('help.sendLogFail', { err: String(e?.message ?? e) }));
|
||||||
|
} finally {
|
||||||
|
setSendingLog(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2940,8 +3066,10 @@ export default function App() {
|
|||||||
// callsign depends on the "ESC clears callsign" option; with the keyer
|
// callsign depends on the "ESC clears callsign" option; with the keyer
|
||||||
// off it always resets the entry (the classic behaviour).
|
// off it always resets the entry (the classic behaviour).
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
// If a voice message is transmitting, ESC just stops it (keeps entry).
|
// If a voice message is transmitting (or auto-CQ is looping), ESC stops it
|
||||||
if (dvkActiveRef.current && dvkPlayingRef.current) {
|
// and cancels the loop (keeps entry).
|
||||||
|
if (dvkActiveRef.current && (dvkPlayingRef.current || dvkAutoCqSlotRef.current >= 0)) {
|
||||||
|
stopDvkAutoCq();
|
||||||
DVKStop();
|
DVKStop();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
@@ -3742,6 +3870,7 @@ export default function App() {
|
|||||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||||
onBulkEdit={openBulkEdit}
|
onBulkEdit={openBulkEdit}
|
||||||
onExportSelected={exportSelectedADIF}
|
onExportSelected={exportSelectedADIF}
|
||||||
|
onExportSelectedFields={exportSelectedFields}
|
||||||
onExportFiltered={exportFilteredADIF}
|
onExportFiltered={exportFilteredADIF}
|
||||||
onDelete={(ids) => setDeletingIds(ids)}
|
onDelete={(ids) => setDeletingIds(ids)}
|
||||||
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
||||||
@@ -3921,6 +4050,20 @@ export default function App() {
|
|||||||
<Zap className="size-4" />
|
<Zap className="size-4" />
|
||||||
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
|
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleCwDecoder}
|
||||||
|
title={cwEnabled ? (mode === 'CW' ? t('cwd.tipOnCw') : t('cwd.tipOnIdle')) : t('cwd.tipOff')}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||||
|
cwOn && cwStatus.active ? 'border-success bg-success-muted text-success-muted-foreground'
|
||||||
|
: cwEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||||
|
: 'border-border text-muted-foreground hover:bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Ear className="size-4" />
|
||||||
|
{cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success animate-pulse" />}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
|
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
|
||||||
@@ -4417,7 +4560,11 @@ export default function App() {
|
|||||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||||
otherwise it shows the QRZ profile photo. */}
|
otherwise it shows the QRZ profile photo. */}
|
||||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
||||||
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
|
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
|
||||||
|
// the DVK with Auto CQ) can't grow the row — the row height stays set by
|
||||||
|
// the entry strip and each widget fills that height, scrolling inside.
|
||||||
|
<div className="flex-1 min-w-0 min-h-0 relative">
|
||||||
|
<div className="absolute inset-0 flex gap-2.5 items-stretch">
|
||||||
{/* Multi-op "who's on air" widget: every operator on the shared logbook,
|
{/* Multi-op "who's on air" widget: every operator on the shared logbook,
|
||||||
their freq/mode (colour-coded) and OpsLog version. */}
|
their freq/mode (colour-coded) and OpsLog version. */}
|
||||||
{showLiveStations && dbConn?.backend === 'mysql' && (
|
{showLiveStations && dbConn?.backend === 'mysql' && (
|
||||||
@@ -4500,13 +4647,18 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{dvkEnabled && (
|
{dvkEnabled && (
|
||||||
<div className="w-[264px] shrink-0 min-h-0">
|
<div className="w-[320px] shrink-0 min-h-0">
|
||||||
<DvkPanel
|
<DvkPanel
|
||||||
messages={dvkMsgs}
|
messages={dvkMsgs}
|
||||||
status={dvkStat}
|
status={dvkStat}
|
||||||
onPlay={dvkPlay}
|
onPlay={dvkPlay}
|
||||||
onStop={() => DVKStop()}
|
onStop={() => { stopDvkAutoCq(); DVKStop(); }}
|
||||||
onClose={() => setDvkEnabled(false)}
|
onClose={() => setDvkEnabled(false)}
|
||||||
|
autoCq={dvkAutoCq}
|
||||||
|
autoCqSecs={dvkAutoCqSecs}
|
||||||
|
onToggleAutoCq={setDvkAutoCq}
|
||||||
|
onSetAutoCqSecs={(n) => setDvkAutoCqSecs(Math.max(0, Math.min(120, n || 0)))}
|
||||||
|
phoneOk={isPhoneMode(mode)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -4571,10 +4723,58 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>{/* /entry + aside row */}
|
</div>{/* /entry + aside row */}
|
||||||
|
|
||||||
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
|
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
|
||||||
|
{cwOn && (
|
||||||
|
<div className="ml-2.5 mt-1.5 -mb-1 w-[45%] flex items-center gap-2 rounded-md border border-success-border/70 bg-success-muted/60 px-2 py-1.5 text-xs">
|
||||||
|
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-success' : 'text-muted-foreground')} />
|
||||||
|
{/* Input-level meter — if this stays flat with a strong signal, the RX
|
||||||
|
audio device is wrong/silent rather than a decode problem. */}
|
||||||
|
<div className="shrink-0 w-12 h-1.5 rounded bg-muted overflow-hidden" title={t('cwd.levelTip', { pct: Math.round(cwStatus.level * 100) })}>
|
||||||
|
<div className="h-full bg-success transition-[width] duration-100" style={{ width: `${Math.min(100, Math.round(cwStatus.level * 100))}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||||
|
{cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'}
|
||||||
|
</span>
|
||||||
|
{/* Lock pitch: blank = Auto (follow the Flex CW pitch / search). */}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={cwPitch}
|
||||||
|
onChange={(e) => setCwPitch(e.target.value)}
|
||||||
|
placeholder="auto"
|
||||||
|
title={t('cwd.pitchTip')}
|
||||||
|
className="shrink-0 w-14 h-5 rounded border border-success-border/70 bg-card/60 px-1 text-[10px] font-mono text-center outline-none"
|
||||||
|
/>
|
||||||
|
{/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
|
||||||
|
text (see cwScrollRef effect) so the latest stays in view. */}
|
||||||
|
<div ref={cwScrollRef} className="flex-1 min-w-0 overflow-hidden font-mono leading-none flex items-center">
|
||||||
|
{cwText.trim() === '' ? (
|
||||||
|
<span className="text-muted-foreground italic">{t('cwd.listening')}</span>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex items-center whitespace-nowrap">
|
||||||
|
{cwText.trim().split(/\s+/).map((tok, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className="mr-1 shrink-0 rounded px-1 leading-none hover:bg-success-muted/70"
|
||||||
|
title={t('cwd.useCall')}
|
||||||
|
onClick={() => onCallsignInput(tok, { force: true })}
|
||||||
|
>
|
||||||
|
{tok}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="shrink-0 text-muted-foreground hover:text-foreground" title={t('cwd.clear')} onClick={() => setCwText('')}>
|
||||||
|
<Eraser className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
||||||
{compact ? null : <>
|
{compact ? null : <>
|
||||||
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
|
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
|
||||||
@@ -4749,6 +4949,7 @@ export default function App() {
|
|||||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||||
onBulkEdit={openBulkEdit}
|
onBulkEdit={openBulkEdit}
|
||||||
onExportSelected={exportSelectedADIF}
|
onExportSelected={exportSelectedADIF}
|
||||||
|
onExportSelectedFields={exportSelectedFields}
|
||||||
onExportFiltered={exportFilteredADIF}
|
onExportFiltered={exportFilteredADIF}
|
||||||
onExportCabrilloSelected={exportSelectedCabrillo}
|
onExportCabrilloSelected={exportSelectedCabrillo}
|
||||||
onExportCabrilloFiltered={exportFilteredCabrillo}
|
onExportCabrilloFiltered={exportFilteredCabrillo}
|
||||||
@@ -5382,10 +5583,8 @@ export default function App() {
|
|||||||
<Dialog open onOpenChange={(o) => { if (!o) setShowExportChoice(false); }}>
|
<Dialog open onOpenChange={(o) => { if (!o) setShowExportChoice(false); }}>
|
||||||
<DialogContent className="max-w-lg px-6">
|
<DialogContent className="max-w-lg px-6">
|
||||||
<DialogHeader className="px-2">
|
<DialogHeader className="px-2">
|
||||||
<DialogTitle>Export ADIF</DialogTitle>
|
<DialogTitle>{t('exp.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{t('exp.desc')}</DialogDescription>
|
||||||
Choose which fields to include. OpsLog writes ADIF 3.1.7.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="px-2 py-1 space-y-2.5">
|
<div className="px-2 py-1 space-y-2.5">
|
||||||
<button
|
<button
|
||||||
@@ -5393,30 +5592,46 @@ export default function App() {
|
|||||||
onClick={() => runExport(false)}
|
onClick={() => runExport(false)}
|
||||||
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
|
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="font-semibold text-sm">Standard ADIF only</div>
|
<div className="font-semibold text-sm">{t('exp.stdTitle')}</div>
|
||||||
<div className="text-xs text-muted-foreground mt-0.5">
|
<div className="text-xs text-muted-foreground mt-0.5">{t('exp.stdDesc')}</div>
|
||||||
Only fields defined in the ADIF 3.1.7 spec — portable to other loggers (Log4OM, N1MM, LoTW…).
|
|
||||||
Application-specific <span className="font-mono">APP_*</span> and any non-standard / vendor tags are stripped.
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => runExport(true)}
|
onClick={() => runExport(true)}
|
||||||
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
|
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="font-semibold text-sm">All fields (OpsLog round-trip)</div>
|
<div className="font-semibold text-sm">{t('exp.allTitle')}</div>
|
||||||
<div className="text-xs text-muted-foreground mt-0.5">
|
<div className="text-xs text-muted-foreground mt-0.5">{t('exp.allDesc')}</div>
|
||||||
Every field including application-specific <span className="font-mono">APP_*</span> and vendor tags —
|
</button>
|
||||||
a lossless backup you'll re-import into OpsLog.
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
onClick={() => { setShowExportChoice(false); setFieldPicker({ count: total }); }}
|
||||||
|
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="font-semibold text-sm">{t('exp.chooseTitle')}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">{t('exp.chooseDesc')}</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="px-2 bg-transparent border-t-0">
|
<DialogFooter className="px-2 bg-transparent border-t-0">
|
||||||
<Button variant="outline" onClick={() => setShowExportChoice(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setShowExportChoice(false)}>{t('exf.cancel')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
{fieldPicker && (
|
||||||
|
<ExportFieldsDialog
|
||||||
|
open
|
||||||
|
count={fieldPicker.count}
|
||||||
|
onClose={() => setFieldPicker(null)}
|
||||||
|
onExport={(fields) => {
|
||||||
|
const fp = fieldPicker;
|
||||||
|
setFieldPicker(null);
|
||||||
|
if (!fp) return;
|
||||||
|
if (fp.ids) exportSelectedADIF(fp.ids, fields);
|
||||||
|
else runExport(false, fields);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{pendingImportPath && (
|
{pendingImportPath && (
|
||||||
<Dialog open onOpenChange={(o) => { if (!o) setPendingImportPath(null); }}>
|
<Dialog open onOpenChange={(o) => { if (!o) setPendingImportPath(null); }}>
|
||||||
<DialogContent className="max-w-lg px-6">
|
<DialogContent className="max-w-lg px-6">
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { Flame } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// AmpCard renders the amplifier card exactly like the one in the FlexRadio panel,
|
||||||
|
// so Station Control (and anywhere else that needs it) shows the SAME card instead
|
||||||
|
// of a stripped-down variant. It handles all three amp families:
|
||||||
|
// • SPE Expert / ACOM — driven by OpsLog's own serial/TCP link (amp.spe/amp.acom)
|
||||||
|
// • PowerGenius XL — OPERATE + meters come from the Flex (which reports the
|
||||||
|
// amp), fan mode from the direct GSCP link (amp.pgxl)
|
||||||
|
// Controls use the multi-amp API (AmpOperate/AmpPower/… by amp id) so several amps
|
||||||
|
// can each get their own card.
|
||||||
|
|
||||||
|
const METER_SEGMENTS = 26;
|
||||||
|
|
||||||
|
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', display, segColor, compact }: {
|
||||||
|
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; display?: string;
|
||||||
|
segColor?: (frac: number) => string; compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const span = hi - lo;
|
||||||
|
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
||||||
|
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
||||||
|
return (
|
||||||
|
<div 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')}>
|
||||||
|
<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={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
|
||||||
|
{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></>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<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) => {
|
||||||
|
const on = i < lit;
|
||||||
|
const frac = i / METER_SEGMENTS;
|
||||||
|
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
|
||||||
|
style={on
|
||||||
|
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
|
||||||
|
: { background: '#cfc6ad', opacity: 0.35 }} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
|
||||||
|
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">
|
||||||
|
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-3">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// speMaxW / powerLevelLabel — same helpers the Flex panel uses.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
function powerLevelLabel(pl?: string): string {
|
||||||
|
switch ((pl || '').trim().toUpperCase()) {
|
||||||
|
case 'L': return 'Low';
|
||||||
|
case 'M': return 'Mid';
|
||||||
|
case 'H': return 'High';
|
||||||
|
default: return pl || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
|
||||||
|
|
||||||
|
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
|
||||||
|
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
|
||||||
|
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||||
|
const peakHold = (key: string, val: number) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const p = peak.current[key];
|
||||||
|
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||||
|
return p.v;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSPE = !!amp.spe;
|
||||||
|
const isACOM = !!amp.acom;
|
||||||
|
|
||||||
|
if (isSPE) {
|
||||||
|
const spe = amp.spe;
|
||||||
|
return (
|
||||||
|
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${amp.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button type="button" disabled={!spe.connected}
|
||||||
|
onClick={() => AmpOperate(amp.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>
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||||
|
<button type="button" disabled={!spe.connected}
|
||||||
|
onClick={() => AmpPower(amp.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(amp.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>
|
||||||
|
<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(amp.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isACOM) {
|
||||||
|
const acom = amp.acom;
|
||||||
|
return (
|
||||||
|
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${amp.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button type="button" disabled={!acom.connected}
|
||||||
|
onClick={() => AmpOperate(amp.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>
|
||||||
|
<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(amp.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(amp.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PowerGenius XL — OPERATE + meters ride on the Flex; fan mode on the GSCP link.
|
||||||
|
const pg = amp.pgxl || {};
|
||||||
|
const viaFlex = !!flex?.amp_available;
|
||||||
|
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
||||||
|
const connected = !!pg.connected || viaFlex;
|
||||||
|
const fault = flex?.amp_fault;
|
||||||
|
return (
|
||||||
|
<Card icon={Flame} title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button type="button" disabled={!connected}
|
||||||
|
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
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')}>
|
||||||
|
{operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
|
||||||
|
</span>
|
||||||
|
{(pg.host || pg.connected) && (
|
||||||
|
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
||||||
|
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||||
|
<span className="text-muted-foreground">{t('flxp.fan')}</span>
|
||||||
|
<select
|
||||||
|
disabled={!pg.connected}
|
||||||
|
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||||||
|
onChange={(e) => AmpFanMode(amp.id, e.target.value).catch(() => {})}
|
||||||
|
className="h-8 rounded-md border border-warning-border bg-card px-2 text-xs font-semibold text-warning outline-none focus:border-warning disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<option value="STANDARD">{t('flxp.fanStandard')}</option>
|
||||||
|
<option value="CONTEST">{t('flxp.fanContest')}</option>
|
||||||
|
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{fault && fault !== 'NONE' && (
|
||||||
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {fault}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Amplifier meters (FWD / ID / TEMP …) from the FlexRadio UDP stream. */}
|
||||||
|
{viaFlex && (() => {
|
||||||
|
const meters = (flex?.meters as any[]) || [];
|
||||||
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
|
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||||
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||||
|
if (amps.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">
|
||||||
|
{amps.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';
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -94,6 +94,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
const dxcc = wb?.dxcc ?? 0;
|
const dxcc = wb?.dxcc ?? 0;
|
||||||
const dxccName = wb?.dxcc_name ?? '';
|
const dxccName = wb?.dxcc_name ?? '';
|
||||||
const dxccCount = wb?.dxcc_count ?? 0;
|
const dxccCount = wb?.dxcc_count ?? 0;
|
||||||
|
const mwRank = wb?.mw_rank ?? 0; // ClubLog Most Wanted rank (1 = most wanted; 0 = off/unknown)
|
||||||
const callCount = wb?.count ?? 0; // QSOs with this exact callsign
|
const callCount = wb?.count ?? 0; // QSOs with this exact callsign
|
||||||
const hasDxcc = dxcc > 0;
|
const hasDxcc = dxcc > 0;
|
||||||
const newOne = hasDxcc && dxccCount === 0;
|
const newOne = hasDxcc && dxccCount === 0;
|
||||||
@@ -136,6 +137,20 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
const newMode = slotNew && bandWorked && !modeWorked;
|
const newMode = slotNew && bandWorked && !modeWorked;
|
||||||
const newSlot = slotNew && bandWorked && modeWorked;
|
const newSlot = slotNew && bandWorked && modeWorked;
|
||||||
|
|
||||||
|
// ClubLog Most Wanted rank pill (shown next to the entity name when the feature
|
||||||
|
// is on). Hotter colour the more wanted the entity is.
|
||||||
|
const mwBadge = mwRank > 0 ? (
|
||||||
|
<Badge
|
||||||
|
title={`ClubLog Most Wanted #${mwRank}`}
|
||||||
|
className={cn('px-1.5 py-0 text-[10px] font-bold shrink-0',
|
||||||
|
mwRank <= 100 ? 'bg-danger text-danger-foreground'
|
||||||
|
: mwRank <= 250 ? 'bg-warning text-warning-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground')}
|
||||||
|
>
|
||||||
|
MW #{mwRank}
|
||||||
|
</Badge>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -154,12 +169,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
<strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
<strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
||||||
{' '}· never worked this entity
|
{' '}· never worked this entity
|
||||||
</span>
|
</span>
|
||||||
|
{mwBadge}
|
||||||
</>
|
</>
|
||||||
) : hasDxcc ? (
|
) : hasDxcc ? (
|
||||||
<>
|
<>
|
||||||
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
|
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
|
||||||
{dxccName || `DXCC #${dxcc}`}
|
{dxccName || `DXCC #${dxcc}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{mwBadge}
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
|
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
|
||||||
QSO{dxccCount > 1 ? 's' : ''} with this entity
|
QSO{dxccCount > 1 ? 's' : ''} with this entity
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type FieldKind = 'status' | 'text';
|
type FieldKind = 'status' | 'text' | 'freq';
|
||||||
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
|
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
|
||||||
|
|
||||||
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
|
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
|
||||||
@@ -74,6 +74,9 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
|
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
|
||||||
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
|
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
|
||||||
// Misc
|
// Misc
|
||||||
|
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
|
||||||
|
// logged on a stale/default frequency after CAT dropped.
|
||||||
|
{ id: 'freq', label: 'bulk.fFreq', group: 'Misc', kind: 'freq' },
|
||||||
{ id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
|
{ id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
|
||||||
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
|
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
|
||||||
{ id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' },
|
{ id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' },
|
||||||
@@ -180,7 +183,8 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|||||||
<Input
|
<Input
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
value={textValue}
|
value={textValue}
|
||||||
placeholder={t('bulk.clearPlaceholder')}
|
inputMode={def.kind === 'freq' ? 'decimal' : undefined}
|
||||||
|
placeholder={def.kind === 'freq' ? t('bulk.freqPlaceholder') : t('bulk.clearPlaceholder')}
|
||||||
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
|
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -188,7 +192,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|||||||
|
|
||||||
<div className="text-[11px] text-muted-foreground">
|
<div className="text-[11px] text-muted-foreground">
|
||||||
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
|
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
|
||||||
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}</span> {t('bulk.onQsos', { n: ids.length })}
|
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}{def.kind === 'freq' && effectiveValue !== '' ? ' MHz' : ''}</span> {t('bulk.onQsos', { n: ids.length })}
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="text-xs text-danger">{error}</div>}
|
{error && <div className="text-xs text-danger">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -149,6 +149,16 @@ 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',
|
||||||
|
// Sort by the real arrival timestamp, NOT the "HHMMZ" display string. A
|
||||||
|
// lexical sort of time_utc breaks at the UTC day rollover: "0001Z" sorts
|
||||||
|
// below "2359Z", so spots received just after midnight fell to the bottom
|
||||||
|
// and looked like the cluster had stopped. received_at is a full datetime
|
||||||
|
// that keeps ordering correct across 0000Z.
|
||||||
|
comparator: (_a: any, _b: any, nodeA: any, nodeB: any) => {
|
||||||
|
const ta = Date.parse(nodeA?.data?.received_at ?? '') || 0;
|
||||||
|
const tb = Date.parse(nodeB?.data?.received_at ?? '') || 0;
|
||||||
|
return ta - tb;
|
||||||
|
},
|
||||||
cellStyle: { color: 'var(--muted-foreground)' },
|
cellStyle: { color: 'var(--muted-foreground)' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Mic, Square, X, Radio } from 'lucide-react';
|
import { Mic, Square, X, Radio, Repeat } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -12,14 +12,20 @@ type Props = {
|
|||||||
onPlay: (slot: number) => void;
|
onPlay: (slot: number) => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
autoCq: boolean;
|
||||||
|
autoCqSecs: number;
|
||||||
|
onToggleAutoCq: (on: boolean) => void;
|
||||||
|
onSetAutoCqSecs: (n: number) => void;
|
||||||
|
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
|
||||||
};
|
};
|
||||||
|
|
||||||
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F6
|
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F6
|
||||||
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
||||||
// the reserved area. Recording/labeling lives in Settings → Audio.
|
// the reserved area. Recording/labeling lives in Settings → Audio.
|
||||||
export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const anyAudio = messages.some((m) => m.has_audio);
|
const recorded = messages.filter((m) => m.has_audio);
|
||||||
|
const anyAudio = recorded.length > 0;
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden">
|
<div className="h-full flex flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden">
|
||||||
<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">
|
||||||
@@ -43,29 +49,54 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
|||||||
{t('dvkp.noMsgPre')} <strong>{t('dvkp.settingsPath')}</strong> {t('dvkp.noMsgPost')}
|
{t('dvkp.noMsgPre')} <strong>{t('dvkp.settingsPath')}</strong> {t('dvkp.noMsgPost')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-1">
|
// Only the recorded slots are shown. Two columns filling top-to-bottom
|
||||||
{messages.map((m) => (
|
// (rows sized to the count) — use the width instead of scrolling, so the
|
||||||
|
// panel keeps the same height as the other reserved-area widgets.
|
||||||
|
<div className="grid grid-flow-col gap-1"
|
||||||
|
style={{ gridTemplateRows: `repeat(${Math.max(1, Math.ceil(recorded.length / 2))}, minmax(0, auto))` }}>
|
||||||
|
{recorded.map((m) => (
|
||||||
<button
|
<button
|
||||||
key={m.slot}
|
key={m.slot}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!m.has_audio}
|
disabled={!phoneOk}
|
||||||
onClick={() => onPlay(m.slot)}
|
onClick={() => onPlay(m.slot)}
|
||||||
title={m.has_audio ? t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) }) : t('dvkp.empty', { slot: m.slot })}
|
title={!phoneOk ? t('dvkp.notPhone') : t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) })}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
|
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
|
||||||
m.has_audio
|
phoneOk
|
||||||
? 'border-border bg-background hover:border-primary/60 hover:bg-accent/30 cursor-pointer'
|
? 'border-border bg-background hover:border-primary/60 hover:bg-accent/30 cursor-pointer'
|
||||||
: 'border-dashed border-border/60 text-muted-foreground/50 cursor-not-allowed',
|
: 'border-dashed border-border/60 text-muted-foreground/50 cursor-not-allowed',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="font-mono text-[11px] font-bold text-primary shrink-0">F{m.slot}</span>
|
<span className="font-mono text-[11px] font-bold text-primary shrink-0">F{m.slot}</span>
|
||||||
<span className="text-xs truncate flex-1">{m.label || (m.has_audio ? t('dvkp.message') : '—')}</span>
|
<span className="text-xs truncate flex-1">{m.label || t('dvkp.message')}</span>
|
||||||
{m.has_audio && <span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>}
|
{(m.label || '').toUpperCase().includes('CQ') && autoCq && <Repeat className="size-3 text-primary shrink-0" />}
|
||||||
|
<span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-CQ footer — repeats a CQ-labelled slot on a timer. Greyed out on a
|
||||||
|
non-phone mode (the DVK won't transmit there). */}
|
||||||
|
<div className="shrink-0 border-t border-border bg-muted/30 px-2 py-1.5 flex items-center gap-2">
|
||||||
|
<label className={cn('flex items-center gap-1.5 text-[11px] cursor-pointer', !phoneOk && 'opacity-50')} title={phoneOk ? t('dvkp.autoCqHint') : t('dvkp.notPhone')}>
|
||||||
|
<input type="checkbox" className="accent-primary" checked={autoCq} disabled={!phoneOk}
|
||||||
|
onChange={(e) => onToggleAutoCq(e.target.checked)} />
|
||||||
|
<Repeat className="size-3" /> {t('dvkp.autoCq')}
|
||||||
|
</label>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<span className="text-[10px] text-muted-foreground">{t('dvkp.gap')}</span>
|
||||||
|
<input type="number" min={0} max={120} value={autoCqSecs}
|
||||||
|
onChange={(e) => onSetAutoCqSecs(parseInt(e.target.value, 10))}
|
||||||
|
className="w-12 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono text-center" />
|
||||||
|
<span className="text-[10px] text-muted-foreground">s</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!phoneOk && (
|
||||||
|
<div className="shrink-0 bg-warning-muted text-warning-muted-foreground text-[10px] px-2 py-1 text-center">{t('dvkp.notPhone')}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { ADIFFields, ADIFExtraFields } from '@/../wailsjs/go/main/App';
|
||||||
|
import { adif } from '@/../wailsjs/go/models';
|
||||||
|
|
||||||
|
type FieldDef = adif.FieldDef;
|
||||||
|
const PREF_KEY = 'opslog.exportFields';
|
||||||
|
|
||||||
|
// ExportFieldsDialog lets the operator pick exactly which ADIF fields an export
|
||||||
|
// writes. Two groups: the official ADIF 3.1.7 dictionary (grouped by category)
|
||||||
|
// and the OpsLog / non-standard tags actually present in the log's extras. The
|
||||||
|
// chosen set is returned to the parent (which runs the selected/global export)
|
||||||
|
// and persisted so it's the default next time.
|
||||||
|
export function ExportFieldsDialog({ open, count, onExport, onClose }: {
|
||||||
|
open: boolean;
|
||||||
|
count: number; // how many QSOs the pending export covers
|
||||||
|
onExport: (fields: string[]) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [official, setOfficial] = useState<FieldDef[]>([]);
|
||||||
|
const [extras, setExtras] = useState<string[]>([]);
|
||||||
|
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const defaultSet = (flds: FieldDef[]) =>
|
||||||
|
new Set(flds.filter((d) => d.promoted && !d.deprecated).map((d) => d.name));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const [f, e] = await Promise.all([ADIFFields(), ADIFExtraFields()]);
|
||||||
|
if (cancelled) return;
|
||||||
|
const flds = (f ?? []) as FieldDef[];
|
||||||
|
setOfficial(flds);
|
||||||
|
setExtras((e ?? []) as string[]);
|
||||||
|
let saved: string[] | null = null;
|
||||||
|
try { const raw = localStorage.getItem(PREF_KEY); if (raw) saved = JSON.parse(raw); } catch { /* ignore */ }
|
||||||
|
setSel(saved && saved.length ? new Set(saved.map((x) => x.toUpperCase())) : defaultSet(flds));
|
||||||
|
})().catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Official fields grouped by category (deprecated ones are import-only → hidden).
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const m = new Map<string, FieldDef[]>();
|
||||||
|
for (const d of official) {
|
||||||
|
if (d.deprecated) continue;
|
||||||
|
const g = d.category || 'Misc';
|
||||||
|
(m.get(g) ?? m.set(g, []).get(g)!).push(d);
|
||||||
|
}
|
||||||
|
return [...m.entries()];
|
||||||
|
}, [official]);
|
||||||
|
|
||||||
|
const toggle = (name: string, on: boolean) =>
|
||||||
|
setSel((s) => { const n = new Set(s); if (on) n.add(name); else n.delete(name); return n; });
|
||||||
|
const setMany = (names: string[], on: boolean) =>
|
||||||
|
setSel((s) => { const n = new Set(s); for (const x of names) { if (on) n.add(x); else n.delete(x); } return n; });
|
||||||
|
|
||||||
|
const doExport = () => {
|
||||||
|
const fields = [...sel];
|
||||||
|
writeUiPref(PREF_KEY, JSON.stringify(fields));
|
||||||
|
onExport(fields);
|
||||||
|
};
|
||||||
|
|
||||||
|
const GroupCard = ({ title, tags, warn }: { title: string; tags: string[]; warn?: boolean }) => (
|
||||||
|
<div className={`rounded-md border p-2 ${warn ? 'border-warning-border/50 bg-warning-muted/20' : 'border-border/60'}`}>
|
||||||
|
<div className="flex items-center justify-between mb-1 gap-2">
|
||||||
|
<span className={`text-[11px] font-semibold uppercase tracking-wide ${warn ? 'text-warning-muted-foreground' : 'text-muted-foreground'}`}>{title}</span>
|
||||||
|
<span className="flex gap-1.5 shrink-0">
|
||||||
|
<button type="button" className="text-[10px] text-primary hover:underline" onClick={() => setMany(tags, true)}>{t('exf.all')}</button>
|
||||||
|
<button type="button" className="text-[10px] text-muted-foreground hover:underline" onClick={() => setMany(tags, false)}>{t('exf.none')}</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{tags.map((name) => (
|
||||||
|
<label key={name} className="flex items-center gap-1.5 text-[11px] cursor-pointer py-0.5">
|
||||||
|
<Checkbox checked={sel.has(name)} onCheckedChange={(c) => toggle(name, !!c)} />
|
||||||
|
<span className="font-mono break-all">{name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('exf.title')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('exf.desc')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
|
||||||
|
<span>{t('exf.chosen', { n: sel.size })}</span>
|
||||||
|
<Button variant="ghost" size="sm" className="ml-auto h-6 text-[11px]" onClick={() => setSel(defaultSet(official))}>
|
||||||
|
{t('exf.defaults')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1">
|
||||||
|
{/* OpsLog / non-standard group first (most relevant to keep or drop). */}
|
||||||
|
{extras.length > 0 && <GroupCard title={t('exf.opslogGroup')} tags={extras} warn />}
|
||||||
|
{groups.map(([g, list]) => (
|
||||||
|
<GroupCard key={g} title={g} tags={list.map((d) => d.name)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>{t('exf.cancel')}</Button>
|
||||||
|
<Button disabled={sel.size === 0} onClick={doExport}>{t('exf.export', { n: count })}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX } from 'lucide-react';
|
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX, ChevronDown, SlidersHorizontal } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||||
@@ -295,6 +295,11 @@ function powerLevelLabel(pl?: string): string {
|
|||||||
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);
|
||||||
|
// Extra/"advanced" DSP rows (WNB + the SmartSDR v4 NRL/NRS/NRF/ANFL/AI-FFT
|
||||||
|
// block) collapse behind a button so the RECEIVE card doesn't grow tall — only
|
||||||
|
// NB/NR/ANF stay visible by default. Remembered across sessions.
|
||||||
|
const [dspOpen, setDspOpen] = useState<boolean>(() => localStorage.getItem('opslog.flexDspOpen') === '1');
|
||||||
|
const toggleDsp = () => setDspOpen((o) => { const n = !o; localStorage.setItem('opslog.flexDspOpen', n ? '1' : '0'); return n; });
|
||||||
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)
|
// 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
|
// in step with the radio's ACTUAL CW speed — including when it's changed on the
|
||||||
@@ -555,11 +560,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
|
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" />,
|
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 input level in dBFS — SmartSDR's scale is -40…0 dB.
|
||||||
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={10} accent="#16a34a"
|
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={0} accent="#16a34a"
|
||||||
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
|
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
|
||||||
// Speech compression (dB of gain reduction).
|
// Speech compression — original working meter, only the top of the
|
||||||
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" />,
|
// scale changed from the radio-reported 20 to 25 (SmartSDR's -25 max).
|
||||||
|
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={25} accent="#0891b2" />,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
||||||
@@ -767,13 +773,28 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
{/* Core noise controls stay visible; WNB + the SmartSDR v4 DSP block
|
||||||
|
hide behind the 'DSP' toggle so the card doesn't tower. When any
|
||||||
|
hidden control is ON we badge the toggle so it's not forgotten. */}
|
||||||
|
{(() => {
|
||||||
|
const hiddenOn = st.wnb || (st.dsp_v4 && (!!st.lms_nr || !!st.speex_nr || !!st.nrf || (!isCW && !!st.lms_anf) || !!st.rnn || (!isCW && !!st.anft)));
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-bold text-muted-foreground">{t('flxp.dspNoise')}</span>
|
||||||
|
<button type="button" onClick={toggleDsp} title={t('flxp.dspMore')}
|
||||||
|
className={cn('flex items-center gap-1 h-6 px-2 rounded-md border text-[10px] font-bold uppercase tracking-wide transition-colors',
|
||||||
|
hiddenOn && !dspOpen ? 'bg-primary/15 text-primary border-primary/40' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
<SlidersHorizontal className="size-3" />
|
||||||
|
DSP
|
||||||
|
{hiddenOn && !dspOpen && <span className="ml-0.5 size-1.5 rounded-full bg-primary" />}
|
||||||
|
<ChevronDown className={cn('size-3 transition-transform', dspOpen && 'rotate-180')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<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))} />
|
||||||
@@ -783,13 +804,19 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
|
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
|
||||||
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
||||||
)}
|
)}
|
||||||
</div>
|
{/* Advanced / supplementary DSP — collapsed by default. WNB (wideband
|
||||||
{/* SmartSDR v4 DSP (8000/Aurora series) — NRL/ANFL (legacy LMS), NRS
|
noise blanker) plus the SmartSDR v4 block (8000/Aurora): NRL/ANFL
|
||||||
(spectral subtraction), NRF (NR w/ filter), RNN (AI NR), ANFT (FFT
|
(legacy LMS), NRS (spectral subtraction), NRF (NR w/ filter), RNN
|
||||||
notch). Only shown when the radio actually reports these slice keys
|
(AI NR), ANFT (FFT notch). The v4 rows only exist when the radio
|
||||||
(older 6000s never do). API keys per the FlexLib slice docs. */}
|
reports those slice keys (older 6000s never do). */}
|
||||||
|
{dspOpen && (
|
||||||
|
<div className="space-y-3 border-t border-dashed border-border/50 pt-3">
|
||||||
|
{/* 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))} />
|
||||||
{st.dsp_v4 && (
|
{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"
|
<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))}
|
onToggle={() => change('lms_nr', !st.lms_nr, () => FlexSetLMSNR(!st.lms_nr))}
|
||||||
onLevel={(v) => change('lms_nr_level', v, () => FlexSetLMSNRLevel(v))} />
|
onLevel={(v) => change('lms_nr_level', v, () => FlexSetLMSNRLevel(v))} />
|
||||||
@@ -825,8 +852,11 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</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"
|
||||||
|
|||||||
@@ -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 { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History } from 'lucide-react';
|
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
@@ -166,6 +166,53 @@ export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHand
|
|||||||
catch { /* ignore */ }
|
catch { /* ignore */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ---- Mic pass order ------------------------------------------------------
|
||||||
|
// The on-air grid shows the stations in the order the mic goes around — NOT
|
||||||
|
// by log time. The order is owned here as a list of draft ids: new check-ins
|
||||||
|
// join at the END of the queue, logged/cancelled ones drop out, and the ⬆⬇
|
||||||
|
// buttons move the selected station. Persisted per net so a panel remount
|
||||||
|
// (tab switch) keeps the round.
|
||||||
|
const [orderIds, setOrderIds] = useState<number[]>([]);
|
||||||
|
const orderKey = openId ? `opslog.netOrder.${openId}` : '';
|
||||||
|
// Load the saved order when a net opens.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!orderKey) { setOrderIds([]); return; }
|
||||||
|
try { setOrderIds(JSON.parse(localStorage.getItem(orderKey) || '[]')); }
|
||||||
|
catch { setOrderIds([]); }
|
||||||
|
}, [orderKey]);
|
||||||
|
// Reconcile with the live list: keep known ids in their order, append new
|
||||||
|
// ones, drop the gone. Persist.
|
||||||
|
useEffect(() => {
|
||||||
|
setOrderIds((cur) => {
|
||||||
|
const liveIds = active.map((a) => a.id as number).filter((id) => id != null);
|
||||||
|
const liveSet = new Set(liveIds);
|
||||||
|
const kept = cur.filter((id) => liveSet.has(id));
|
||||||
|
const keptSet = new Set(kept);
|
||||||
|
const next = [...kept, ...liveIds.filter((id) => !keptSet.has(id))];
|
||||||
|
if (next.length === cur.length && next.every((v, i) => v === cur[i])) return cur;
|
||||||
|
if (orderKey) { try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch { /* quota */ } }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [active, orderKey]);
|
||||||
|
const activeOrdered = useMemo(() => {
|
||||||
|
const byId = new Map(active.map((a) => [a.id as number, a]));
|
||||||
|
return orderIds.map((id) => byId.get(id)).filter(Boolean) as QSOForm[];
|
||||||
|
}, [active, orderIds]);
|
||||||
|
// Move the selected on-air station up/down the queue.
|
||||||
|
const moveSelected = useCallback((dir: -1 | 1) => {
|
||||||
|
const id = selectedActiveIds[0];
|
||||||
|
if (id == null) return;
|
||||||
|
setOrderIds((cur) => {
|
||||||
|
const i = cur.indexOf(id);
|
||||||
|
const j = i + dir;
|
||||||
|
if (i < 0 || j < 0 || j >= cur.length) return cur;
|
||||||
|
const next = [...cur];
|
||||||
|
[next[i], next[j]] = [next[j], next[i]];
|
||||||
|
if (orderKey) { try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch { /* quota */ } }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [selectedActiveIds, orderKey]);
|
||||||
|
|
||||||
useEffect(() => { refreshNets(); }, [refreshNets]);
|
useEffect(() => { refreshNets(); }, [refreshNets]);
|
||||||
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
|
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
|
||||||
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
|
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
|
||||||
@@ -221,11 +268,11 @@ export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHand
|
|||||||
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
|
// Then auto-select the NEXT station in the mic-pass order, so the operator
|
||||||
// chain "log → log → log" without re-clicking a row each time.
|
// 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;
|
||||||
const next = active.find((a) => a.id !== id); // computed BEFORE the list shrinks
|
const next = activeOrdered.find((a) => a.id !== id); // computed BEFORE the list shrinks
|
||||||
try {
|
try {
|
||||||
await NetDeactivate(id);
|
await NetDeactivate(id);
|
||||||
await refreshActive();
|
await refreshActive();
|
||||||
@@ -356,11 +403,12 @@ export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHand
|
|||||||
</div>
|
</div>
|
||||||
<div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
|
<div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
rows={active}
|
rows={activeOrdered}
|
||||||
total={active.length}
|
total={activeOrdered.length}
|
||||||
storageKey="net.onair"
|
storageKey="net.onair"
|
||||||
selectRowSignal={selectRow}
|
selectRowSignal={selectRow}
|
||||||
rowDragCall
|
rowDragCall
|
||||||
|
passOrder
|
||||||
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
|
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
|
||||||
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
||||||
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
||||||
@@ -373,6 +421,19 @@ export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHand
|
|||||||
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
|
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
|
||||||
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Mic pass order: move the selected station up/down the round. */}
|
||||||
|
<div className="flex items-center gap-0.5 pl-1 border-l border-border/50">
|
||||||
|
<Button variant="ghost" size="icon" className="size-7" title={t('ncp.moveUp')}
|
||||||
|
disabled={selectedActiveIds[0] == null || orderIds.indexOf(selectedActiveIds[0]) <= 0}
|
||||||
|
onClick={() => moveSelected(-1)}>
|
||||||
|
<ChevronUp className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="size-7" title={t('ncp.moveDown')}
|
||||||
|
disabled={selectedActiveIds[0] == null || orderIds.indexOf(selectedActiveIds[0]) >= orderIds.length - 1}
|
||||||
|
onClick={() => moveSelected(1)}>
|
||||||
|
<ChevronDown className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto" onClick={logAll}>
|
<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 })}
|
<MinusCircle className="size-3.5" /> {t('ncp.logAll', { n: active.length })}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Props = {
|
|||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
|
onExportSelectedFields?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onExportCabrilloFiltered?: () => void;
|
onExportCabrilloFiltered?: () => void;
|
||||||
@@ -35,7 +36,7 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
|||||||
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
||||||
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||||
// which used to dismiss the menu the instant it appeared.)
|
// which used to dismiss the menu the instant it appeared.)
|
||||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
@@ -137,6 +138,15 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onExportSelectedFields && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onExportSelectedFields(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<FileDown className="size-4 text-info" />
|
||||||
|
<span>{t('qctx.exportSelectedFields', { n })}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onExportFiltered && (
|
{onExportFiltered && (
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ type Props = {
|
|||||||
// row onto the roster to log it). Pair with onGridApi so the parent can
|
// row onto the roster to log it). Pair with onGridApi so the parent can
|
||||||
// register external drop zones via api.addRowDropZone.
|
// register external drop zones via api.addRowDropZone.
|
||||||
rowDragCall?: boolean;
|
rowDragCall?: boolean;
|
||||||
|
// Pass-order mode (NET Control on-air queue): the ROW ORDER GIVEN IN `rows`
|
||||||
|
// is the display order — a pinned "#" column numbers it, and all column
|
||||||
|
// sorting is disabled (including saved sorts) so the queue can't be shuffled
|
||||||
|
// by a header click. The parent owns/reorders the array.
|
||||||
|
passOrder?: boolean;
|
||||||
onGridApi?: (api: any) => void;
|
onGridApi?: (api: any) => void;
|
||||||
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
||||||
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
||||||
@@ -53,6 +58,7 @@ type Props = {
|
|||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
onBulkEdit?: (ids: number[]) => void;
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
|
onExportSelectedFields?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||||
onExportCabrilloFiltered?: () => void;
|
onExportCabrilloFiltered?: () => void;
|
||||||
@@ -257,7 +263,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
|||||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, 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);
|
||||||
@@ -330,8 +336,20 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
|||||||
if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) {
|
if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) {
|
||||||
col.rowDrag = true;
|
col.rowDrag = true;
|
||||||
}
|
}
|
||||||
|
if (passOrder) {
|
||||||
|
delete (col as any).sort; // e.g. the date column's default 'desc'
|
||||||
|
col.sortable = false;
|
||||||
|
}
|
||||||
return col;
|
return col;
|
||||||
});
|
});
|
||||||
|
if (passOrder) {
|
||||||
|
base.unshift({
|
||||||
|
colId: '__order', headerName: '#', width: 46, pinned: 'left',
|
||||||
|
sortable: false, resizable: false, suppressMovable: true, filter: false,
|
||||||
|
cellClass: 'font-mono text-muted-foreground tabular-nums',
|
||||||
|
valueGetter: (p) => (p.node?.rowIndex ?? 0) + 1,
|
||||||
|
} as ColDef<QSOForm>);
|
||||||
|
}
|
||||||
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
||||||
colId: `award_${a.code}`,
|
colId: `award_${a.code}`,
|
||||||
headerName: a.code,
|
headerName: a.code,
|
||||||
@@ -344,7 +362,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
|||||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||||
}));
|
}));
|
||||||
return [...base, ...awards];
|
return [...base, ...awards];
|
||||||
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall]);
|
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall, passOrder]);
|
||||||
|
|
||||||
// Enforce award-column visibility via the API after the columns (re)appear —
|
// Enforce award-column visibility via the API after the columns (re)appear —
|
||||||
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
||||||
@@ -360,21 +378,27 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
|||||||
}, [awardCols, awardShown]);
|
}, [awardCols, awardShown]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true,
|
sortable: !passOrder,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
filter: true,
|
filter: true,
|
||||||
suppressMovable: false,
|
suppressMovable: false,
|
||||||
}), []);
|
}), [passOrder]);
|
||||||
|
|
||||||
|
// In pass-order mode a saved sort (from before the mode existed, or synced
|
||||||
|
// from elsewhere) must not shuffle the queue — strip sorts when restoring.
|
||||||
|
const sanitizeState = useCallback((state: any[]) => (
|
||||||
|
passOrder ? state.map((s) => ({ ...s, sort: null })) : state
|
||||||
|
), [passOrder]);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
function onGridReady(e: GridReadyEvent) {
|
||||||
onGridApi?.(e.api);
|
onGridApi?.(e.api);
|
||||||
const local = loadLocal(colStateKey);
|
const local = loadLocal(colStateKey);
|
||||||
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
if (local) e.api.applyColumnState({ state: sanitizeState(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(colStateKey).then((remote) => {
|
loadRemote(colStateKey).then((remote) => {
|
||||||
if (remote && !local) {
|
if (remote && !local) {
|
||||||
e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true });
|
e.api.applyColumnState({ state: sanitizeState(stripAwardCols(remote)) as ColumnState[], applyOrder: true });
|
||||||
seedLocal(colStateKey, remote);
|
seedLocal(colStateKey, remote);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -384,9 +408,16 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
|||||||
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
||||||
const api = e?.api ?? gridRef.current?.api;
|
const api = e?.api ?? gridRef.current?.api;
|
||||||
if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
|
if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
|
||||||
|
// Pass-order "#" is a rowIndex-based valueGetter; AG-Grid keeps the same
|
||||||
|
// row node across a reorder (getRowId by id), so the number is cached and
|
||||||
|
// stale until we force it. Refresh it on every model update (a reorder
|
||||||
|
// fires one). force:true bypasses the value cache.
|
||||||
|
if (passOrder && api?.refreshCells) {
|
||||||
|
api.refreshCells({ columns: ['__order'], force: true });
|
||||||
|
}
|
||||||
if (!api || !onFilteredCountChange) return;
|
if (!api || !onFilteredCountChange) return;
|
||||||
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
||||||
}, [onFilteredCountChange]);
|
}, [onFilteredCountChange, passOrder]);
|
||||||
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();
|
||||||
@@ -403,7 +434,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
const local = loadLocal(colStateKey);
|
const local = loadLocal(colStateKey);
|
||||||
if (api && local) api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
if (api && local) api.applyColumnState({ state: sanitizeState(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);
|
||||||
@@ -570,6 +601,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
|||||||
onSendEQSL={onSendEQSL}
|
onSendEQSL={onSendEQSL}
|
||||||
onBulkEdit={onBulkEdit}
|
onBulkEdit={onBulkEdit}
|
||||||
onExportSelected={onExportSelected}
|
onExportSelected={onExportSelected}
|
||||||
|
onExportSelectedFields={onExportSelectedFields}
|
||||||
onExportFiltered={onExportFiltered}
|
onExportFiltered={onExportFiltered}
|
||||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||||
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||||
|
GetClublogMostWantedInfo, SetClublogMostWantedEnabled, DownloadClublogMostWanted,
|
||||||
GetSecretStatus, SetPassphrase, RemovePassphrase,
|
GetSecretStatus, SetPassphrase, RemovePassphrase,
|
||||||
GetEmailSettings, SaveEmailSettings, TestEmail,
|
GetEmailSettings, SaveEmailSettings, TestEmail,
|
||||||
QSLGetEmailTemplates, QSLSaveEmailTemplates,
|
QSLGetEmailTemplates, QSLSaveEmailTemplates,
|
||||||
@@ -28,7 +29,7 @@ import {
|
|||||||
ConnectClusterServer, DisconnectClusterServer,
|
ConnectClusterServer, DisconnectClusterServer,
|
||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase,
|
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase, RevealDataFolder, RenameLogbook,
|
||||||
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
||||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||||
@@ -1054,8 +1055,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
|
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
|
||||||
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; tx_inhibit: boolean }>({
|
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; tx_inhibit: boolean; freq_min_mhz: number; freq_max_mhz: number }>({
|
||||||
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, tx_inhibit: false,
|
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, tx_inhibit: false, freq_min_mhz: 13, freq_max_mhz: 54,
|
||||||
});
|
});
|
||||||
const [ubTesting, setUbTesting] = useState(false);
|
const [ubTesting, setUbTesting] = useState(false);
|
||||||
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
@@ -1074,13 +1075,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
port: string; baud: number; wpm: number; weight: number;
|
port: string; baud: number; wpm: number; weight: number;
|
||||||
lead_in_ms: number; tail_ms: number; ratio: number; farnsworth: number;
|
lead_in_ms: number; tail_ms: number; ratio: number; farnsworth: number;
|
||||||
sidetone_hz: number; mode: string; swap: boolean; autospace: boolean;
|
sidetone_hz: number; mode: string; swap: boolean; autospace: boolean;
|
||||||
use_ptt: boolean; serial_echo: boolean; macros: WKMac[];
|
use_ptt: boolean; serial_echo: boolean; cw_key_line: string; cw_invert: boolean; macros: WKMac[];
|
||||||
};
|
};
|
||||||
const [wk, setWk] = useState<WKSettings>({
|
const [wk, setWk] = useState<WKSettings>({
|
||||||
enabled: false, engine: 'winkeyer', esc_clears_call: true,
|
enabled: false, engine: 'winkeyer', esc_clears_call: true,
|
||||||
port: '', baud: 1200, wpm: 25, weight: 50, lead_in_ms: 10,
|
port: '', baud: 1200, wpm: 25, weight: 50, lead_in_ms: 10,
|
||||||
tail_ms: 50, ratio: 50, farnsworth: 0, sidetone_hz: 600, mode: 'iambic_b',
|
tail_ms: 50, ratio: 50, farnsworth: 0, sidetone_hz: 600, mode: 'iambic_b',
|
||||||
swap: false, autospace: true, use_ptt: false, serial_echo: true, macros: [],
|
swap: false, autospace: true, use_ptt: false, serial_echo: true, cw_key_line: 'dtr', cw_invert: false, macros: [],
|
||||||
});
|
});
|
||||||
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
const [wkPorts, setWkPorts] = useState<string[]>([]);
|
||||||
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
|
||||||
@@ -1148,7 +1149,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const refreshSecret = async () => { try { setSecret(await GetSecretStatus() as any); } catch {} };
|
const refreshSecret = async () => { try { setSecret(await GetSecretStatus() as any); } catch {} };
|
||||||
useEffect(() => { refreshSecret(); }, []);
|
useEffect(() => { refreshSecret(); }, []);
|
||||||
const applyPassphrase = async () => {
|
const applyPassphrase = async () => {
|
||||||
if (ppNew !== ppConfirm) { setPpErr('Passphrases do not match'); return; }
|
if (ppNew !== ppConfirm) { setPpErr(t('gen.pwMismatch')); return; }
|
||||||
setPpBusy(true); setPpErr('');
|
setPpBusy(true); setPpErr('');
|
||||||
try { await SetPassphrase(ppNew); setPpNew(''); setPpConfirm(''); await refreshSecret(); }
|
try { await SetPassphrase(ppNew); setPpNew(''); setPpConfirm(''); await refreshSecret(); }
|
||||||
catch (e: any) { setPpErr(String(e?.message ?? e)); }
|
catch (e: any) { setPpErr(String(e?.message ?? e)); }
|
||||||
@@ -1183,6 +1184,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [clubBusy, setClubBusy] = useState(false);
|
const [clubBusy, setClubBusy] = useState(false);
|
||||||
const [clubErr, setClubErr] = useState('');
|
const [clubErr, setClubErr] = useState('');
|
||||||
useEffect(() => { GetClublogCtyInfo().then((i) => setClubInfo(i as ClubInfo)).catch(() => {}); }, []);
|
useEffect(() => { GetClublogCtyInfo().then((i) => setClubInfo(i as ClubInfo)).catch(() => {}); }, []);
|
||||||
|
// ClubLog Most Wanted (rank shown in the entry matrix).
|
||||||
|
type MWInfo = { enabled: boolean; loaded: boolean; callsign: string; date: string; count: number };
|
||||||
|
const [mwInfo, setMwInfo] = useState<MWInfo>({ enabled: false, loaded: false, callsign: '', date: '', count: 0 });
|
||||||
|
const [mwBusy, setMwBusy] = useState(false);
|
||||||
|
const [mwErr, setMwErr] = useState('');
|
||||||
|
useEffect(() => { GetClublogMostWantedInfo().then((i) => setMwInfo(i as MWInfo)).catch(() => {}); }, []);
|
||||||
const reloadDvk = () => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); };
|
const reloadDvk = () => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); };
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
|
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
|
||||||
@@ -1290,9 +1297,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [backupRunning, setBackupRunning] = useState(false);
|
const [backupRunning, setBackupRunning] = useState(false);
|
||||||
const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false });
|
const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean; logbook_default_path?: string }>({ path: '', default_path: '', is_custom: false });
|
||||||
const [dbMsg, setDbMsg] = useState('');
|
const [dbMsg, setDbMsg] = useState('');
|
||||||
type MySQLCfg = { enabled: boolean; host: string; port: number; user: string; password: string; database: string };
|
type MySQLCfg = { enabled: boolean; host: string; port: number; user: string; password: string; database: string; sqlite_path?: string };
|
||||||
const [mysqlCfg, setMysqlCfg] = useState<MySQLCfg>({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' });
|
const [mysqlCfg, setMysqlCfg] = useState<MySQLCfg>({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' });
|
||||||
const setMysqlField = (patch: Partial<MySQLCfg>) => setMysqlCfg((s) => ({ ...s, ...patch }));
|
const setMysqlField = (patch: Partial<MySQLCfg>) => setMysqlCfg((s) => ({ ...s, ...patch }));
|
||||||
const [mysqlMsg, setMysqlMsg] = useState('');
|
const [mysqlMsg, setMysqlMsg] = useState('');
|
||||||
@@ -2632,6 +2639,22 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isSteppir && (
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Label className="text-sm">{t('hw.steppirRange')}</Label>
|
||||||
|
<input type="number" min={1} max={60} value={ultrabeam.freq_min_mhz}
|
||||||
|
onChange={(e) => setUltrabeam((s) => ({ ...s, freq_min_mhz: parseInt(e.target.value, 10) || 0 }))}
|
||||||
|
className="h-8 w-16 rounded-md border border-input bg-background px-2 text-sm" />
|
||||||
|
<span className="text-xs text-muted-foreground">–</span>
|
||||||
|
<input type="number" min={1} max={60} value={ultrabeam.freq_max_mhz}
|
||||||
|
onChange={(e) => setUltrabeam((s) => ({ ...s, freq_max_mhz: parseInt(e.target.value, 10) || 0 }))}
|
||||||
|
className="h-8 w-16 rounded-md border border-input bg-background px-2 text-sm" />
|
||||||
|
<span className="text-xs text-muted-foreground">MHz</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('hw.steppirRangeHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="border-t border-border/60 pt-3 space-y-1">
|
<div className="border-t border-border/60 pt-3 space-y-1">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={ultrabeam.tx_inhibit} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, tx_inhibit: !!c }))} />
|
<Checkbox checked={ultrabeam.tx_inhibit} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, tx_inhibit: !!c }))} />
|
||||||
@@ -3014,44 +3037,45 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={wk.enabled} onCheckedChange={(c) => setWkField({ enabled: !!c })} />
|
<Checkbox checked={wk.enabled} onCheckedChange={(c) => setWkField({ enabled: !!c })} />
|
||||||
Enable CW keyer (shows the keyer panel)
|
{t('wk.enable')}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-3 items-end">
|
<div className="grid grid-cols-4 gap-3 items-end">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Keyer engine</Label>
|
<Label>{t('wk.engine')}</Label>
|
||||||
<Select value={wk.engine} onValueChange={(v) => setWkField({ engine: v })}>
|
{/* Picking the serial engine defaults PTT-keying ON: without RTS held,
|
||||||
|
the rig drops to RX between words (semi-break-in). */}
|
||||||
|
<Select value={wk.engine} onValueChange={(v) => setWkField(v === 'serial' ? { engine: v, use_ptt: true } : { engine: v })}>
|
||||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
|
<SelectItem value="winkeyer">{t('wk.engWinkeyer')}</SelectItem>
|
||||||
<SelectItem value="icom">Icom CI-V (rig keyer)</SelectItem>
|
<SelectItem value="serial">{t('wk.engSerial')}</SelectItem>
|
||||||
<SelectItem value="flex">FlexRadio (CWX)</SelectItem>
|
<SelectItem value="icom">{t('wk.engIcom')}</SelectItem>
|
||||||
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
|
<SelectItem value="flex">{t('wk.engFlex')}</SelectItem>
|
||||||
|
<SelectItem value="tci" disabled>{t('wk.engTci')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer col-span-3 pb-1.5">
|
<label className="flex items-center gap-2 text-sm cursor-pointer col-span-3 pb-1.5">
|
||||||
<Checkbox checked={wk.esc_clears_call} onCheckedChange={(c) => setWkField({ esc_clears_call: !!c })} />
|
<Checkbox checked={wk.esc_clears_call} onCheckedChange={(c) => setWkField({ esc_clears_call: !!c })} />
|
||||||
ESC clears the callsign too (otherwise ESC only stops transmission)
|
{t('wk.escClears')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{wk.engine === 'icom' ? (
|
{wk.engine === 'icom' ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-xs text-muted-foreground -mt-2">
|
<p className="text-xs text-muted-foreground -mt-2">
|
||||||
Icom CI-V keys CW through the rig's own keyer over the existing CAT connection (command 0x17) — it reuses the CAT COM port set in Settings → CAT, so there's nothing else to wire up here. Put the rig in CW mode. Weight, ratio, sidetone, paddle mode… are configured on the radio; only the speed is set from here (the rig's KEY SPEED).
|
{t('wk.icomNote')}
|
||||||
</p>
|
</p>
|
||||||
{(!catCfg.enabled || catCfg.backend !== 'icom') && (
|
{(!catCfg.enabled || catCfg.backend !== 'icom') && (
|
||||||
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||||||
<span aria-hidden>⚠</span>
|
<span aria-hidden>⚠</span>
|
||||||
<span>
|
<span>{t('wk.catWarnIcom', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
|
||||||
Your CAT backend is set to <strong>{catCfg.enabled ? (catCfg.backend || 'none') : 'disabled'}</strong>. Icom CI-V CW needs the CAT backend set to <strong>Icom</strong> and connected — change it under Settings → CAT interface, otherwise sending CW will fail.
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Speed (WPM)</Label>
|
<Label>{t('wk.speed')}</Label>
|
||||||
<Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
<Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3059,44 +3083,98 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
) : wk.engine === 'flex' ? (
|
) : wk.engine === 'flex' ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-xs text-muted-foreground -mt-2">
|
<p className="text-xs text-muted-foreground -mt-2">
|
||||||
FlexRadio keys CW through the radio's <strong>CWX</strong> keyer over the existing SmartSDR CAT connection — no WinKeyer or SmartCAT needed. It reuses the connection set in Settings → CAT, so there's nothing else to wire up here. Put a slice in CW mode. Only the speed is set from here; weight, sidetone and break-in are configured on the radio (break-in must be on for CW to actually transmit).
|
{t('wk.flexNote')}
|
||||||
</p>
|
</p>
|
||||||
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
||||||
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||||||
<span aria-hidden>⚠</span>
|
<span aria-hidden>⚠</span>
|
||||||
<span>
|
<span>{t('wk.catWarnFlex', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
|
||||||
Your CAT backend is set to <strong>{catCfg.enabled ? (catCfg.backend || 'none') : 'disabled'}</strong>. Flex CWX needs the CAT backend set to <strong>FlexRadio</strong> and connected — change it under Settings → CAT interface, otherwise sending CW will fail.
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Speed (WPM)</Label>
|
<Label>{t('wk.speed')}</Label>
|
||||||
<Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
<Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : wk.engine === 'serial' ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label>Serial port</Label>
|
<Label>{t('wk.keyingPort')}</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={wk.port || '_'} onValueChange={(v) => setWkField({ port: v === '_' ? '' : v })}>
|
<Select value={wk.port || '_'} onValueChange={(v) => setWkField({ port: v === '_' ? '' : v })}>
|
||||||
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder="— COM port —" /></SelectTrigger>
|
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder={t('wk.comPort')} /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
{wkPorts.length === 0 && <SelectItem value="_" disabled>{t('wk.noPorts')}</SelectItem>}
|
||||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button variant="ghost" size="sm" className="h-8 px-2" title="Reload ports"
|
<Button variant="ghost" size="sm" className="h-8 px-2" title={t('wk.reloadPorts')}
|
||||||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
<ArrowDown className="size-3.5 rotate-90" />
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Baud</Label>
|
<Label>{t('wk.cwKeyLine')}</Label>
|
||||||
|
<Select value={wk.cw_key_line === 'rts' ? 'rts' : 'dtr'} onValueChange={(v) => setWkField({ cw_key_line: v })}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="dtr">{t('wk.lineDtr')}</SelectItem>
|
||||||
|
<SelectItem value="rts">{t('wk.lineRts')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('wk.speed')}</Label>
|
||||||
|
<Input type="number" min={5} max={99} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('wk.farnsworth')}</Label>
|
||||||
|
<Input type="number" min={0} max={99} value={wk.farnsworth} onChange={(e) => setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('wk.leadIn')}</Label>
|
||||||
|
<Input type="number" min={0} value={wk.lead_in_ms} onChange={(e) => setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('wk.tail')}</Label>
|
||||||
|
<Input type="number" min={0} value={wk.tail_ms} onChange={(e) => setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer pb-1.5 self-end">
|
||||||
|
<Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> {t('wk.keyPtt')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={wk.cw_invert} onCheckedChange={(c) => setWkField({ cw_invert: !!c })} />
|
||||||
|
{t('wk.invert')}
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>{t('wk.serialPort')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={wk.port || '_'} onValueChange={(v) => setWkField({ port: v === '_' ? '' : v })}>
|
||||||
|
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder={t('wk.comPort')} /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{wkPorts.length === 0 && <SelectItem value="_" disabled>{t('wk.noPorts')}</SelectItem>}
|
||||||
|
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 px-2" title={t('wk.reloadPorts')}
|
||||||
|
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('wk.baud')}</Label>
|
||||||
<Select value={String(wk.baud)} onValueChange={(v) => setWkField({ baud: num(v, 1200) })}>
|
<Select value={String(wk.baud)} onValueChange={(v) => setWkField({ baud: num(v, 1200) })}>
|
||||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -3105,38 +3183,38 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Speed (WPM)</Label>
|
<Label>{t('wk.speed')}</Label>
|
||||||
<Input type="number" min={5} max={99} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
<Input type="number" min={5} max={99} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Weight</Label>
|
<Label>{t('wk.weight')}</Label>
|
||||||
<Input type="number" min={10} max={90} value={wk.weight} onChange={(e) => setWkField({ weight: num(e.target.value, 50) })} className="font-mono" />
|
<Input type="number" min={10} max={90} value={wk.weight} onChange={(e) => setWkField({ weight: num(e.target.value, 50) })} className="font-mono" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Lead-in (ms)</Label>
|
<Label>{t('wk.leadIn')}</Label>
|
||||||
<Input type="number" min={0} value={wk.lead_in_ms} onChange={(e) => setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" />
|
<Input type="number" min={0} value={wk.lead_in_ms} onChange={(e) => setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Tail (ms)</Label>
|
<Label>{t('wk.tail')}</Label>
|
||||||
<Input type="number" min={0} value={wk.tail_ms} onChange={(e) => setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" />
|
<Input type="number" min={0} value={wk.tail_ms} onChange={(e) => setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Ratio (33-66)</Label>
|
<Label>{t('wk.ratio')}</Label>
|
||||||
<Input type="number" min={33} max={66} value={wk.ratio} onChange={(e) => setWkField({ ratio: num(e.target.value, 50) })} className="font-mono" />
|
<Input type="number" min={33} max={66} value={wk.ratio} onChange={(e) => setWkField({ ratio: num(e.target.value, 50) })} className="font-mono" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Farnsworth</Label>
|
<Label>{t('wk.farnsworth')}</Label>
|
||||||
<Input type="number" min={0} max={99} value={wk.farnsworth} onChange={(e) => setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" />
|
<Input type="number" min={0} max={99} value={wk.farnsworth} onChange={(e) => setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Sidetone (Hz)</Label>
|
<Label>{t('wk.sidetone')}</Label>
|
||||||
<Input type="number" min={0} value={wk.sidetone_hz} onChange={(e) => setWkField({ sidetone_hz: num(e.target.value, 600) })} className="font-mono" />
|
<Input type="number" min={0} value={wk.sidetone_hz} onChange={(e) => setWkField({ sidetone_hz: num(e.target.value, 600) })} className="font-mono" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label>Paddle mode</Label>
|
<Label>{t('wk.paddleMode')}</Label>
|
||||||
<Select value={wk.mode} onValueChange={(v) => setWkField({ mode: v })}>
|
<Select value={wk.mode} onValueChange={(v) => setWkField({ mode: v })}>
|
||||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -3151,16 +3229,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
|
|
||||||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={wk.swap} onCheckedChange={(c) => setWkField({ swap: !!c })} /> Swap paddles
|
<Checkbox checked={wk.swap} onCheckedChange={(c) => setWkField({ swap: !!c })} /> {t('wk.swapPaddles')}
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={wk.autospace} onCheckedChange={(c) => setWkField({ autospace: !!c })} /> Auto-space
|
<Checkbox checked={wk.autospace} onCheckedChange={(c) => setWkField({ autospace: !!c })} /> {t('wk.autospace')}
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> Key PTT
|
<Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> {t('wk.keyPttShort')}
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> Serial echo
|
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> {t('wk.serialEcho')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -3168,9 +3246,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
|
|
||||||
{/* Macro editor */}
|
{/* Macro editor */}
|
||||||
<div className="border-t border-border/60 pt-3">
|
<div className="border-t border-border/60 pt-3">
|
||||||
<Label className="text-sm font-medium">CW message macros (F1…)</Label>
|
<Label className="text-sm font-medium">{t('wk.macroTitle')}</Label>
|
||||||
<p className="text-[11px] text-muted-foreground mb-2">
|
<p className="text-[11px] text-muted-foreground mb-2">
|
||||||
Use variables: <span className="font-mono"><MY_CALL> <CALL> <STX> <STRX> <MY_NAME> <HIS_NAME> <MY_QTH> <GRID> <CONT_TX> <n></span> (cut numbers: 9→N, 0→T). <span className="font-mono">*</span>=my call, <span className="font-mono">!</span>=his call. <span className="font-mono"><LOGQSO></span> = log the QSO when the macro is sent (e.g. <span className="font-mono">73 TU <LOGQSO></span>).
|
{t('wk.macroVars')} <span className="font-mono"><MY_CALL> <CALL> <STX> <STRX> <MY_NAME> <HIS_NAME> <MY_QTH> <GRID> <CONT_TX> <n></span> {t('wk.macroCut')} <span className="font-mono">*</span>={t('wk.macroMyCall')}, <span className="font-mono">!</span>={t('wk.macroHisCall')}. <span className="font-mono"><LOGQSO></span> = {t('wk.macroLog')} (<span className="font-mono">73 TU <LOGQSO></span>).
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1">
|
<div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1">
|
||||||
{Array.from({ length: 12 }).map((_, i) => {
|
{Array.from({ length: 12 }).map((_, i) => {
|
||||||
@@ -3178,7 +3256,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
return (
|
return (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
<span className="font-mono text-[10px] text-primary font-semibold w-6 shrink-0">F{i + 1}</span>
|
<span className="font-mono text-[10px] text-primary font-semibold w-6 shrink-0">F{i + 1}</span>
|
||||||
<Input className="h-8 w-28 shrink-0 text-xs" placeholder="Label" value={m.label} onChange={(e) => setMacro(i, { label: e.target.value })} />
|
<Input className="h-8 w-28 shrink-0 text-xs" placeholder={t('wk.label')} value={m.label} onChange={(e) => setMacro(i, { label: e.target.value })} />
|
||||||
<Input className="h-8 flex-1 font-mono text-xs" placeholder="CQ CQ DE <MY_CALL> K" value={m.text} onChange={(e) => setMacro(i, { text: e.target.value })} />
|
<Input className="h-8 flex-1 font-mono text-xs" placeholder="CQ CQ DE <MY_CALL> K" value={m.text} onChange={(e) => setMacro(i, { text: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -3743,20 +3821,20 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
|
|
||||||
{/* Tab strip */}
|
{/* Tab strip */}
|
||||||
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
|
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
|
||||||
{TABS.map((t) => (
|
{TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={t.k}
|
key={tab.k}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExtSvcTab(t.k)}
|
onClick={() => setExtSvcTab(tab.k)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-3 py-1.5 text-xs font-semibold rounded-t-md border-x border-t -mb-px transition-colors',
|
'px-3 py-1.5 text-xs font-semibold rounded-t-md border-x border-t -mb-px transition-colors',
|
||||||
extSvcTab === t.k
|
extSvcTab === tab.k
|
||||||
? 'bg-card border-border text-foreground'
|
? 'bg-card border-border text-foreground'
|
||||||
: 'bg-muted/40 border-transparent text-muted-foreground hover:text-foreground',
|
: 'bg-muted/40 border-transparent text-muted-foreground hover:text-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t.label}
|
{tab.label}
|
||||||
{!t.ready && <span className="ml-1 text-[9px] opacity-60">soon</span>}
|
{!tab.ready && <span className="ml-1 text-[9px] opacity-60">{t('es.soon')}</span>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -3768,14 +3846,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Input
|
<Input
|
||||||
value={qrz.api_key}
|
value={qrz.api_key}
|
||||||
onChange={(e) => setQrz({ api_key: e.target.value })}
|
onChange={(e) => setQrz({ api_key: e.target.value })}
|
||||||
placeholder="QRZ.com logbook API key (XXXX-XXXX-XXXX-XXXX)"
|
placeholder={t('es.qrzApiPh')}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">{t('es.forceCall')}</Label>
|
<Label className="text-sm">{t('es.forceCall')}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={qrz.force_station_callsign}
|
value={qrz.force_station_callsign}
|
||||||
onChange={(e) => setQrz({ force_station_callsign: e.target.value.toUpperCase() })}
|
onChange={(e) => setQrz({ force_station_callsign: e.target.value.toUpperCase() })}
|
||||||
placeholder="e.g. F4BPO — optional"
|
placeholder={t('es.forceCallPh')}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -3824,7 +3902,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
type="email"
|
type="email"
|
||||||
value={clublog.email}
|
value={clublog.email}
|
||||||
onChange={(e) => setClublog({ email: e.target.value })}
|
onChange={(e) => setClublog({ email: e.target.value })}
|
||||||
placeholder="your Club Log account email"
|
placeholder={t('es.clubEmailPh')}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">{t('es.password')}</Label>
|
<Label className="text-sm">{t('es.password')}</Label>
|
||||||
@@ -3832,14 +3910,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
type="password"
|
type="password"
|
||||||
value={clublog.password}
|
value={clublog.password}
|
||||||
onChange={(e) => setClublog({ password: e.target.value })}
|
onChange={(e) => setClublog({ password: e.target.value })}
|
||||||
placeholder="Club Log account password"
|
placeholder={t('es.clubPwPh')}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">{t('es.logbookCall')}</Label>
|
<Label className="text-sm">{t('es.logbookCall')}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={clublog.callsign}
|
value={clublog.callsign}
|
||||||
onChange={(e) => setClublog({ callsign: e.target.value.toUpperCase() })}
|
onChange={(e) => setClublog({ callsign: e.target.value.toUpperCase() })}
|
||||||
placeholder="defaults to the active profile's callsign"
|
placeholder={t('es.callDefaultPh')}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -3883,24 +3961,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
) : extSvcTab === 'hrdlog' ? (
|
) : extSvcTab === 'hrdlog' ? (
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
<Label className="text-sm">Station callsign</Label>
|
<Label className="text-sm">{t('es.stationCall')}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={hrdlog.callsign}
|
value={hrdlog.callsign}
|
||||||
onChange={(e) => setHrdlog({ callsign: e.target.value.toUpperCase() })}
|
onChange={(e) => setHrdlog({ callsign: e.target.value.toUpperCase() })}
|
||||||
placeholder="defaults to the active profile's callsign"
|
placeholder={t('es.callDefaultPh')}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">Upload code</Label>
|
<Label className="text-sm">{t('es.uploadCode')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={hrdlog.code}
|
value={hrdlog.code}
|
||||||
onChange={(e) => setHrdlog({ code: e.target.value })}
|
onChange={(e) => setHrdlog({ code: e.target.value })}
|
||||||
placeholder="HRDLog account → My Account → Upload code"
|
placeholder={t('es.hrdCodePh')}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground -mt-1">
|
<div className="text-[10px] text-muted-foreground -mt-1">
|
||||||
Find the upload code on HRDLog.net under <em>My Account</em>. It authorises uploads for this callsign.
|
{t('es.hrdHint')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
@@ -3942,11 +4020,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
) : extSvcTab === 'eqsl' ? (
|
) : extSvcTab === 'eqsl' ? (
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
<Label className="text-sm">Username (callsign)</Label>
|
<Label className="text-sm">{t('es.usernameCall')}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={eqsl.username}
|
value={eqsl.username}
|
||||||
onChange={(e) => setEqsl({ username: e.target.value.toUpperCase() })}
|
onChange={(e) => setEqsl({ username: e.target.value.toUpperCase() })}
|
||||||
placeholder="your eQSL.cc login (callsign)"
|
placeholder={t('es.eqslUserPh')}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">{t('es.password')}</Label>
|
<Label className="text-sm">{t('es.password')}</Label>
|
||||||
@@ -3954,19 +4032,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
type="password"
|
type="password"
|
||||||
value={eqsl.password}
|
value={eqsl.password}
|
||||||
onChange={(e) => setEqsl({ password: e.target.value })}
|
onChange={(e) => setEqsl({ password: e.target.value })}
|
||||||
placeholder="eQSL.cc account password"
|
placeholder={t('es.eqslPwPh')}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">QTH nickname</Label>
|
<Label className="text-sm">{t('es.qthNick')}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={eqsl.qth_nickname}
|
value={eqsl.qth_nickname}
|
||||||
onChange={(e) => setEqsl({ qth_nickname: e.target.value })}
|
onChange={(e) => setEqsl({ qth_nickname: e.target.value })}
|
||||||
placeholder="optional — required only if your eQSL account has several QTHs"
|
placeholder={t('es.qthNickPh')}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground -mt-1">
|
<div className="text-[10px] text-muted-foreground -mt-1">
|
||||||
The QTH nickname tells eQSL which location profile to file the QSO under. Leave blank if you have only one.
|
{t('es.eqslHint')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
@@ -4008,38 +4086,38 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
) : extSvcTab === 'lotw' ? (
|
) : extSvcTab === 'lotw' ? (
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
<Label className="text-sm">LoTW user</Label>
|
<Label className="text-sm">{t('es.lotwUser')}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={lotw.username}
|
value={lotw.username}
|
||||||
onChange={(e) => setLotw({ username: e.target.value })}
|
onChange={(e) => setLotw({ username: e.target.value })}
|
||||||
placeholder="LoTW website login (for downloading confirmations)"
|
placeholder={t('es.lotwUserPh')}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">LoTW password</Label>
|
<Label className="text-sm">{t('es.lotwPw')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={lotw.password}
|
value={lotw.password}
|
||||||
onChange={(e) => setLotw({ password: e.target.value })}
|
onChange={(e) => setLotw({ password: e.target.value })}
|
||||||
placeholder="LoTW website password"
|
placeholder={t('es.lotwPwPh')}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">TQSL path</Label>
|
<Label className="text-sm">{t('es.tqslPath')}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={lotw.tqsl_path}
|
value={lotw.tqsl_path}
|
||||||
onChange={(e) => setLotw({ tqsl_path: e.target.value })}
|
onChange={(e) => setLotw({ tqsl_path: e.target.value })}
|
||||||
placeholder="C:\Program Files (x86)\TrustedQSL\tqsl.exe"
|
placeholder="C:\Program Files (x86)\TrustedQSL\tqsl.exe"
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">Station location</Label>
|
<Label className="text-sm">{t('es.stationLoc')}</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={lotw.station_location || '_'} onValueChange={(v) => setLotw({ station_location: v === '_' ? '' : v })}>
|
<Select value={lotw.station_location || '_'} onValueChange={(v) => setLotw({ station_location: v === '_' ? '' : v })}>
|
||||||
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder="— pick a TQSL location —" /></SelectTrigger>
|
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder={t('es.pickLoc')} /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{stationLocations.length === 0 && <SelectItem value="_" disabled>No TQSL locations found</SelectItem>}
|
{stationLocations.length === 0 && <SelectItem value="_" disabled>{t('es.noLoc')}</SelectItem>}
|
||||||
{stationLocations.map((n) => <SelectItem key={n} value={n}>{n}</SelectItem>)}
|
{stationLocations.map((n) => <SelectItem key={n} value={n}>{n}</SelectItem>)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={refreshLocations} title="Reload locations from TQSL">
|
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={refreshLocations} title={t('es.reloadLoc')}>
|
||||||
<ArrowDown className="size-3.5 rotate-90" />
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -4048,23 +4126,22 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Input
|
<Input
|
||||||
value={lotw.force_station_callsign}
|
value={lotw.force_station_callsign}
|
||||||
onChange={(e) => setLotw({ force_station_callsign: e.target.value.toUpperCase() })}
|
onChange={(e) => setLotw({ force_station_callsign: e.target.value.toUpperCase() })}
|
||||||
placeholder="e.g. F4BPO/P — leave blank to use the QSO's own call"
|
placeholder={t('es.lotwForcePh')}
|
||||||
className="font-mono uppercase w-64"
|
className="font-mono uppercase w-64"
|
||||||
/>
|
/>
|
||||||
<div className="text-[10px] text-muted-foreground mt-1">
|
<div className="text-[10px] text-muted-foreground mt-1">
|
||||||
Overrides STATION_CALLSIGN at sign time so one certificate can sign several calls
|
{t('es.lotwForceHint')}
|
||||||
(F4BPO, F4BPO/P, TM2Q). Pick the matching Station Location above.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Label className="text-sm">Key password</Label>
|
<Label className="text-sm">{t('es.keyPw')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={lotw.key_password}
|
value={lotw.key_password}
|
||||||
onChange={(e) => setLotw({ key_password: e.target.value })}
|
onChange={(e) => setLotw({ key_password: e.target.value })}
|
||||||
placeholder="only if your certificate key has a password"
|
placeholder={t('es.keyPwPh')}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">Consider as unsent</Label>
|
<Label className="text-sm">{t('es.considerUnsent')}</Label>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{(['N', 'R'] as const).map((f) => {
|
{(['N', 'R'] as const).map((f) => {
|
||||||
@@ -4081,15 +4158,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
setLotw({ upload_flags: next });
|
setLotw({ upload_flags: next });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{f === 'N' ? 'No (N)' : 'Requested (R)'}
|
{f === 'N' ? t('es.flagN') : t('es.flagR')}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground mt-1">
|
<div className="text-[10px] text-muted-foreground mt-1">
|
||||||
At app close, every QSO whose LoTW <em>sent</em> status is one of these is signed and
|
{t('es.lotwUnsentHint')}
|
||||||
uploaded in one TQSL batch — including QSOs imported from an ADIF. Uploaded QSOs become
|
|
||||||
<em> Y</em> and won't be re-sent. Must include your default <em>sent</em> status from Confirmations.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -4100,14 +4175,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
checked={lotw.auto_upload}
|
checked={lotw.auto_upload}
|
||||||
onCheckedChange={(c) => setLotw({ auto_upload: !!c, upload_mode: 'on_close' })}
|
onCheckedChange={(c) => setLotw({ auto_upload: !!c, upload_mode: 'on_close' })}
|
||||||
/>
|
/>
|
||||||
Automatic upload on application close
|
{t('es.lotwAutoClose')}
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={lotw.write_log}
|
checked={lotw.write_log}
|
||||||
onCheckedChange={(c) => setLotw({ write_log: !!c })}
|
onCheckedChange={(c) => setLotw({ write_log: !!c })}
|
||||||
/>
|
/>
|
||||||
Write TQSL diagnostic log (-t)
|
{t('es.lotwWriteLog')}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -4142,13 +4217,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
) : extSvcTab === 'pota' ? (
|
) : extSvcTab === 'pota' ? (
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
Update your QSOs with the park reference from your <strong>pota.app hunter log</strong>.
|
{t('es.potaHint')}
|
||||||
Paste your session token: log in at <span className="font-mono">pota.app</span>, open the browser
|
|
||||||
DevTools → <strong>Network</strong> tab, click any <span className="font-mono">api.pota.app</span> request,
|
|
||||||
and copy the full <strong>Authorization</strong> header value. The token expires after a while — re-copy it if the sync fails.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-start">
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-start">
|
||||||
<Label className="text-sm pt-2">Session token</Label>
|
<Label className="text-sm pt-2">{t('es.sessionToken')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={potaToken}
|
value={potaToken}
|
||||||
onChange={(e) => setPotaToken(e.target.value)}
|
onChange={(e) => setPotaToken(e.target.value)}
|
||||||
@@ -4158,10 +4230,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button onClick={savePotaToken} disabled={potaBusy}>
|
<Button onClick={savePotaToken} disabled={potaBusy}>
|
||||||
{potaBusy ? <><Loader2 className="size-4 mr-1.5 animate-spin" /> Saving…</> : 'Save token'}
|
{potaBusy ? <><Loader2 className="size-4 mr-1.5 animate-spin" /> {t('es.saving')}</> : t('es.saveToken')}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<span className="text-[11px] text-muted-foreground">
|
||||||
Then run the sync from the <strong>QSL Manager</strong> tab → service <strong>POTA hunter log</strong> (you can see and fix unmatched QSOs there).
|
{t('es.potaThen')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{potaResult && (
|
{potaResult && (
|
||||||
@@ -4175,9 +4247,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-16">
|
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-16">
|
||||||
<Construction className="size-10 opacity-30" />
|
<Construction className="size-10 opacity-30" />
|
||||||
<div className="text-sm font-semibold text-foreground/70">
|
<div className="text-sm font-semibold text-foreground/70">
|
||||||
{TABS.find((t) => t.k === extSvcTab)?.label} — coming soon
|
{t('es.comingSoon', { name: TABS.find((tab) => tab.k === extSvcTab)?.label ?? '' })}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs">This external service isn't wired up yet.</div>
|
<div className="text-xs">{t('es.notWired')}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -4235,13 +4307,48 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
setDbMsg(dbSettings.default_path || '');
|
setDbMsg(dbSettings.default_path || '');
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
function revealFolder() { RevealDataFolder().catch((e: any) => setErr(String(e?.message ?? e))); }
|
||||||
|
// Rename/relocate THIS profile's logbook, carrying the QSOs across.
|
||||||
|
async function renameLogbook() {
|
||||||
|
try {
|
||||||
|
const p = await PickSaveDatabase();
|
||||||
|
if (!p) return;
|
||||||
|
await RenameLogbook(p);
|
||||||
|
setMysqlField({ enabled: false, sqlite_path: p });
|
||||||
|
setRestartMsg(t('db.logbookRenamed'));
|
||||||
|
await refreshBackend();
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
// Switching the logbook backend applies immediately (no restart): the local
|
// Switching the logbook backend applies immediately (no restart): the local
|
||||||
// SQLite file always stays the config store; only the QSO logbook moves.
|
// SQLite file always stays the config store; only the QSO logbook moves.
|
||||||
function useLocalLogbook() {
|
function useLocalLogbook() {
|
||||||
SaveMySQLSettings({ ...mysqlCfg, enabled: false } as any)
|
SaveMySQLSettings({ ...mysqlCfg, enabled: false, sqlite_path: '' } as any)
|
||||||
.then(async () => { setMysqlField({ enabled: false }); setRestartMsg(t('db.switchedSqlite')); await refreshBackend(); })
|
.then(async () => { setMysqlField({ enabled: false, sqlite_path: '' }); setRestartMsg(t('db.switchedSqlite')); await refreshBackend(); })
|
||||||
.catch((e: any) => setErr(String(e?.message ?? e)));
|
.catch((e: any) => setErr(String(e?.message ?? e)));
|
||||||
}
|
}
|
||||||
|
// Point THIS profile's logbook at a SQLite file (settings stay in the
|
||||||
|
// settings db). A NEW name is created + migrated empty; an existing file is
|
||||||
|
// opened with its QSOs.
|
||||||
|
async function newLogbook() {
|
||||||
|
try {
|
||||||
|
const p = await PickSaveDatabase();
|
||||||
|
if (!p) return;
|
||||||
|
await SaveMySQLSettings({ ...mysqlCfg, enabled: false, sqlite_path: p } as any);
|
||||||
|
setMysqlField({ enabled: false, sqlite_path: p });
|
||||||
|
setRestartMsg(t('db.switchedSqliteFile'));
|
||||||
|
await refreshBackend();
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function openLogbook() {
|
||||||
|
try {
|
||||||
|
const p = await PickOpenDatabase();
|
||||||
|
if (!p) return;
|
||||||
|
await SaveMySQLSettings({ ...mysqlCfg, enabled: false, sqlite_path: p } as any);
|
||||||
|
setMysqlField({ enabled: false, sqlite_path: p });
|
||||||
|
setRestartMsg(t('db.switchedSqliteFile'));
|
||||||
|
await refreshBackend();
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
function connectMysql() {
|
function connectMysql() {
|
||||||
SaveMySQLSettings(mysqlCfg as any)
|
SaveMySQLSettings(mysqlCfg as any)
|
||||||
.then(async () => { setRestartMsg(t('db.switchedMysql')); await refreshBackend(); })
|
.then(async () => { setRestartMsg(t('db.switchedMysql')); await refreshBackend(); })
|
||||||
@@ -4251,18 +4358,31 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<>
|
<>
|
||||||
<SectionHeader title={t('sec.database')} />
|
<SectionHeader title={t('sec.database')} />
|
||||||
|
|
||||||
{/* Logbook backend: local SQLite file (solo) or shared MySQL (multi-op).
|
{/* Settings / application database (settings + profiles) — always shown,
|
||||||
Switching is instant — no restart. Only changing the local SQLite
|
distinct from the QSO logbook so the two are never confused. */}
|
||||||
FILE needs a restart (it also stores this operator's settings). */}
|
<div className="space-y-2 max-w-2xl mb-5 border border-border/60 rounded-md p-3">
|
||||||
|
<Label>{t('db.appDb')}</Label>
|
||||||
|
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
|
||||||
|
{dbSettings.path || '—'}
|
||||||
|
{dbSettings.is_custom
|
||||||
|
? <span className="ml-2 text-[10px] text-success">{t('db.customLoc')}</span>
|
||||||
|
: <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('db.appDbHint')}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={revealFolder}><FolderOpen className="size-3.5" /> {t('db.openFolder')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logbook (QSOs) — this profile: default SQLite file, a dedicated file, or MySQL. */}
|
||||||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
|
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
|
||||||
<Label className="text-sm">{t('db.backend')}</Label>
|
<Label className="text-sm">{t('db.logbookLabel')}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
|
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
const enabled = v === 'mysql';
|
|
||||||
setMysqlField({ enabled });
|
|
||||||
setRestartMsg('');
|
setRestartMsg('');
|
||||||
if (!enabled) useLocalLogbook(); // switching to local applies at once
|
if (v === 'mysql') { setMysqlField({ enabled: true }); return; }
|
||||||
|
useLocalLogbook(); // SQLite → default logbook file (clears any per-profile path)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
|
||||||
@@ -4284,50 +4404,29 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
) : (
|
) : (
|
||||||
<div className="max-w-2xl mb-4 text-[11px] text-muted-foreground">
|
<div className="max-w-2xl mb-4 text-[11px] text-muted-foreground">
|
||||||
{t('db.activeBackend')} <strong className="uppercase text-foreground">{backendStatus.active}</strong>
|
{t('db.activeBackend')} <strong className="uppercase text-foreground">{backendStatus.active}</strong>
|
||||||
{backendStatus.active === 'mysql' && <span> · {t('db.configLocal')}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* SQLite: local logbook file management */}
|
{/* SQLite logbook file: default logbook.db, or a dedicated file for this profile. */}
|
||||||
{!mysqlCfg.enabled && (
|
{!mysqlCfg.enabled && (
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-3 max-w-2xl">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('db.current')}</Label>
|
<Label>{t('db.logbookFile')}</Label>
|
||||||
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
|
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
|
||||||
{dbSettings.path || '—'}
|
{mysqlCfg.sqlite_path || dbSettings.logbook_default_path || '—'}
|
||||||
{dbSettings.is_custom
|
{mysqlCfg.sqlite_path
|
||||||
? <span className="ml-2 text-[10px] text-success">{t('db.customLoc')}</span>
|
? <span className="ml-2 text-[10px] text-success">{t('db.dedicatedFile')}</span>
|
||||||
: <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
|
: <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground">{t('db.defaultLabel')} <span className="font-mono">{dbSettings.default_path}</span></div>
|
<p className="text-[11px] text-muted-foreground">{t('db.logbookFileHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
|
<Button variant="outline" size="sm" onClick={newLogbook}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
|
||||||
<Button variant="outline" size="sm" onClick={openExisting}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
|
<Button variant="outline" size="sm" onClick={openLogbook}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
|
||||||
<Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button>
|
<Button variant="outline" size="sm" onClick={renameLogbook} title={t('db.renameLogbookTip')}><Pencil className="size-3.5" /> {t('db.renameLogbook')}</Button>
|
||||||
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
|
{mysqlCfg.sqlite_path && <Button variant="ghost" size="sm" onClick={useLocalLogbook}>{t('db.useDefaultLogbook')}</Button>}
|
||||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dbMsg && (
|
|
||||||
<div className="text-xs bg-success-muted border border-success-border text-success-muted-foreground rounded-md px-3 py-3 space-y-2">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<Check className="size-4 mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{t('db.savedRestart')}</div>
|
|
||||||
<div className="font-mono text-[10px] mt-1 break-all opacity-90">{dbMsg}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 pl-6">
|
|
||||||
<Button size="sm" onClick={() => { RestartApp().catch((e: any) => setErr(String(e?.message ?? e))); }}>
|
|
||||||
<Power className="size-3.5" /> {t('db.restartNow')}
|
|
||||||
</Button>
|
|
||||||
<span className="text-[10px] opacity-80">{t('db.restartHint')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -4643,35 +4742,35 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} />
|
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} />
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||||
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
|
<h4 className="text-sm font-semibold text-foreground">{t('gen.pwEnc')}</h4>
|
||||||
{secret.has_passphrase ? (
|
{secret.has_passphrase ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Passwords encrypted{' '}
|
{t('gen.pwEncrypted')}{' '}
|
||||||
{secret.unlocked
|
{secret.unlocked
|
||||||
? <span className="text-success font-medium">— unlocked</span>
|
? <span className="text-success font-medium">{t('gen.pwUnlocked')}</span>
|
||||||
: <span className="text-warning font-medium">— locked (unlock at launch)</span>}.
|
: <span className="text-warning font-medium">{t('gen.pwLocked')}</span>}.
|
||||||
</p>
|
</p>
|
||||||
{secret.unlocked && (
|
{secret.unlocked && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 max-w-md">
|
<div className="flex items-center gap-2 max-w-md">
|
||||||
<Input type="password" placeholder="New passphrase (to change)" value={ppNew} onChange={(e) => { setPpNew(e.target.value); setPpErr(''); }} className="h-8" />
|
<Input type="password" placeholder={t('gen.pwNewPh')} value={ppNew} onChange={(e) => { setPpNew(e.target.value); setPpErr(''); }} className="h-8" />
|
||||||
<Input type="password" placeholder="Confirm" value={ppConfirm} onChange={(e) => { setPpConfirm(e.target.value); setPpErr(''); }} className="h-8" />
|
<Input type="password" placeholder={t('gen.pwConfirm')} value={ppConfirm} onChange={(e) => { setPpConfirm(e.target.value); setPpErr(''); }} className="h-8" />
|
||||||
<Button size="sm" disabled={!ppNew || ppBusy} onClick={applyPassphrase}>Change</Button>
|
<Button size="sm" disabled={!ppNew || ppBusy} onClick={applyPassphrase}>{t('gen.pwChange')}</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" disabled={ppBusy} onClick={removePassphrase}>Remove encryption (store passwords in clear)</Button>
|
<Button variant="outline" size="sm" disabled={ppBusy} onClick={removePassphrase}>{t('gen.pwRemove')}</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Encrypt saved passwords with a passphrase (asked at launch). Stays portable — re-enter it on another PC.
|
{t('gen.pwHint')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 max-w-md">
|
<div className="flex items-center gap-2 max-w-md">
|
||||||
<Input type="password" placeholder="Passphrase" value={ppNew} onChange={(e) => { setPpNew(e.target.value); setPpErr(''); }} className="h-8" />
|
<Input type="password" placeholder={t('gen.pwPassphrase')} value={ppNew} onChange={(e) => { setPpNew(e.target.value); setPpErr(''); }} className="h-8" />
|
||||||
<Input type="password" placeholder="Confirm" value={ppConfirm} onChange={(e) => { setPpConfirm(e.target.value); setPpErr(''); }} className="h-8" />
|
<Input type="password" placeholder={t('gen.pwConfirm')} value={ppConfirm} onChange={(e) => { setPpConfirm(e.target.value); setPpErr(''); }} className="h-8" />
|
||||||
<Button size="sm" disabled={!ppNew || ppNew !== ppConfirm || ppBusy} onClick={applyPassphrase}>Encrypt</Button>
|
<Button size="sm" disabled={!ppNew || ppNew !== ppConfirm || ppBusy} onClick={applyPassphrase}>{t('gen.pwEncrypt')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -4679,7 +4778,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||||
<h4 className="text-sm font-semibold text-foreground">ClubLog exceptions (DXpedition overrides)</h4>
|
<h4 className="text-sm font-semibold text-foreground">{t('gen.clubTitle')}</h4>
|
||||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={clubInfo.enabled}
|
checked={clubInfo.enabled}
|
||||||
@@ -4700,27 +4799,67 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
className="mt-0.5"
|
className="mt-0.5"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
Use ClubLog Country File for callsign resolution
|
{t('gen.clubUse')}
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||||
Applies ClubLog's date-ranged full-callsign <strong>exceptions</strong> that cty.dat lacks — e.g. VK2/SP9FIH
|
{t('gen.clubDesc')}
|
||||||
resolves to Lord Howe Island (not Australia) for the DXpedition dates. Used on entry, import, UDP, and the
|
|
||||||
right-click <em>Update from ClubLog</em>.
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-3 pl-6">
|
<div className="flex items-center gap-3 pl-6">
|
||||||
<Button variant="outline" size="sm" className="h-8" disabled={clubBusy}
|
<Button variant="outline" size="sm" className="h-8" disabled={clubBusy}
|
||||||
onClick={() => { setClubBusy(true); setClubErr(''); DownloadClublogCty().then((i) => setClubInfo(i as ClubInfo)).catch((e: any) => setClubErr(String(e?.message ?? e))).finally(() => setClubBusy(false)); }}>
|
onClick={() => { setClubBusy(true); setClubErr(''); DownloadClublogCty().then((i) => setClubInfo(i as ClubInfo)).catch((e: any) => setClubErr(String(e?.message ?? e))).finally(() => setClubBusy(false)); }}>
|
||||||
{clubBusy ? 'Downloading…' : (clubInfo.loaded ? 'Update ClubLog data' : 'Download ClubLog data')}
|
{clubBusy ? t('gen.downloading') : (clubInfo.loaded ? t('gen.clubUpdate') : t('gen.clubDownload'))}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<span className="text-[11px] text-muted-foreground">
|
||||||
{clubInfo.loaded
|
{clubInfo.loaded
|
||||||
? `${clubInfo.count.toLocaleString()} exceptions${clubInfo.date ? ' · ' + clubInfo.date.slice(0, 10) : ''}`
|
? t('gen.clubCount', { n: clubInfo.count.toLocaleString() }) + (clubInfo.date ? ' · ' + clubInfo.date.slice(0, 10) : '')
|
||||||
: 'not downloaded'}
|
: t('gen.notDownloaded')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{clubErr && <p className="text-[11px] text-destructive pl-6">{clubErr}</p>}
|
{clubErr && <p className="text-[11px] text-destructive pl-6">{clubErr}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground">{t('gen.mwTitle')}</h4>
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={mwInfo.enabled}
|
||||||
|
onCheckedChange={async (c) => {
|
||||||
|
const v = !!c; setMwInfo((s) => ({ ...s, enabled: v })); setMwErr('');
|
||||||
|
try {
|
||||||
|
await SetClublogMostWantedEnabled(v);
|
||||||
|
let info = (await GetClublogMostWantedInfo()) as MWInfo;
|
||||||
|
// First enable with no cached list → fetch it now (for the active call).
|
||||||
|
if (v && !info.loaded) {
|
||||||
|
setMwBusy(true);
|
||||||
|
try { info = (await DownloadClublogMostWanted()) as MWInfo; }
|
||||||
|
finally { setMwBusy(false); }
|
||||||
|
}
|
||||||
|
setMwInfo(info);
|
||||||
|
} catch (e: any) { setMwErr(String(e?.message ?? e)); }
|
||||||
|
}}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t('gen.mwShow')}
|
||||||
|
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||||
|
{t('gen.mwDesc')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3 pl-6">
|
||||||
|
<Button variant="outline" size="sm" className="h-8" disabled={mwBusy}
|
||||||
|
onClick={() => { setMwBusy(true); setMwErr(''); DownloadClublogMostWanted().then((i) => setMwInfo(i as MWInfo)).catch((e: any) => setMwErr(String(e?.message ?? e))).finally(() => setMwBusy(false)); }}>
|
||||||
|
{mwBusy ? t('gen.downloading') : (mwInfo.loaded ? t('gen.mwUpdate') : t('gen.mwDownload'))}
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{mwInfo.loaded
|
||||||
|
? t('gen.mwCount', { n: mwInfo.count.toLocaleString() }) + (mwInfo.callsign ? ' · ' + mwInfo.callsign : '') + (mwInfo.date ? ' · ' + mwInfo.date : '')
|
||||||
|
: t('gen.notDownloaded')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{mwErr && <p className="text-[11px] text-destructive pl-6">{mwErr}</p>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, Flame } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -8,12 +8,13 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { RotorCompass } from '@/components/RotorCompass';
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
|
import { AmpCard } from '@/components/AmpCard';
|
||||||
import {
|
import {
|
||||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||||
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, GetFlexState, FlexAmpOperate,
|
GetAmpStatuses, GetFlexState,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||||
@@ -206,9 +207,11 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
|
|||||||
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
|
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{/* Hide 0-length elements — an Ultrabeam reports 6 slots but a
|
{/* The READ_BANDS reply is undocumented and its 16-bit parse picks
|
||||||
3-element beam only uses the first few. */}
|
up structural bytes past the real data (varying by band), which
|
||||||
{lengths.map((mm, i) => ({ mm, i })).filter((e) => e.mm > 0).map(({ mm, i }) => (
|
made 6+ bogus "elements" appear. Ultrabeam beams are 3-element,
|
||||||
|
and ModifyElement addresses elements 0..2 — so show just those. */}
|
||||||
|
{lengths.map((mm, i) => ({ mm, i })).slice(0, 3).map(({ mm, i }) => (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<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>
|
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
|
||||||
<button type="button" disabled={!ant.connected || busyEl !== null}
|
<button type="button" disabled={!ant.connected || busyEl !== null}
|
||||||
@@ -247,139 +250,6 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// AmplifierWidget brings the amplifier controls (Settings → Amplifier) into the
|
|
||||||
// Station Control tab, so an operator WITHOUT a FlexRadio/Icom panel still has
|
|
||||||
// them. Several amps can be configured (some ops run two SPEs in parallel) —
|
|
||||||
// the header dropdown picks which one this card shows; the choice is remembered.
|
|
||||||
function AmplifierWidget({ t }: { t: (k: string, v?: any) => string }) {
|
|
||||||
const [list, setList] = useState<any[]>([]);
|
|
||||||
const [sel, setSel] = useState<string>(() => localStorage.getItem('opslog.ampSel.station') || '');
|
|
||||||
const [flex, setFlex] = useState<any>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true;
|
|
||||||
// The Flex state rides along because a PGXL's OPERATE state comes from the
|
|
||||||
// radio (the direct GSCP status doesn't carry it).
|
|
||||||
const tick = () => Promise.all([
|
|
||||||
GetAmpStatuses().catch(() => []),
|
|
||||||
GetFlexState().catch(() => null),
|
|
||||||
]).then(([l, fx]: any[]) => { if (alive) { setList((l ?? []) as any[]); setFlex(fx); } });
|
|
||||||
tick();
|
|
||||||
const id = window.setInterval(tick, 1500);
|
|
||||||
return () => { alive = false; window.clearInterval(id); };
|
|
||||||
}, []);
|
|
||||||
const amp = list.find((a) => a.id === sel) ?? list[0];
|
|
||||||
if (!amp) return null;
|
|
||||||
const isACOM = !!amp.acom;
|
|
||||||
const isSPE = !!amp.spe;
|
|
||||||
const isPGXL = !isACOM && !isSPE;
|
|
||||||
const viaFlex = isPGXL && !!flex?.amp_available;
|
|
||||||
const raw = amp.spe ?? amp.acom ?? amp.pgxl ?? { connected: false };
|
|
||||||
const st: any = isPGXL
|
|
||||||
? { ...raw, connected: !!raw.connected || viaFlex, operate: viaFlex ? !!flex.amp_operate : !!raw.operate }
|
|
||||||
: raw;
|
|
||||||
const doOperate = () => {
|
|
||||||
const want = !st.operate;
|
|
||||||
(isPGXL && viaFlex ? FlexAmpOperate(want) : AmpOperate(amp.id, want)).catch(() => {});
|
|
||||||
};
|
|
||||||
const maxW = isACOM ? (Number(st.max_w) || 800) : ({ '13K': 1300, '15K': 1500, '2K': 2000 } as Record<string, number>)[st.model] || 1500;
|
|
||||||
const outW = Number(isACOM ? st.fwd_w : st.output_w) || 0;
|
|
||||||
const frac = Math.min(1, outW / maxW);
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
|
||||||
<Flame className="size-4 text-primary" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-sm font-semibold truncate">{t('flxp.amplifier')}</div>
|
|
||||||
{list.length <= 1 && <div className="text-[10px] text-muted-foreground font-mono truncate">{amp.name}</div>}
|
|
||||||
</div>
|
|
||||||
{list.length > 1 && (
|
|
||||||
<select value={amp.id} title={t('flxp.ampPick')}
|
|
||||||
onChange={(e) => { setSel(e.target.value); localStorage.setItem('opslog.ampSel.station', e.target.value); }}
|
|
||||||
className="h-7 rounded-md border border-border bg-card px-1.5 text-xs font-semibold outline-none min-w-0">
|
|
||||||
{list.map((a: any) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
<span className={cn('ml-auto size-2 rounded-full shrink-0', st.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
|
||||||
title={st.connected ? t('station.online') : (st.last_error || t('station.offline'))} />
|
|
||||||
</div>
|
|
||||||
<div className="p-3 space-y-2">
|
|
||||||
{isPGXL ? (
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<button type="button" disabled={!st.connected}
|
|
||||||
onClick={doOperate}
|
|
||||||
className={cn('px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 transition-all disabled:opacity-40',
|
|
||||||
st.operate ? 'bg-warning text-warning-foreground border-warning' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
|
||||||
{st.operate ? 'OPERATE' : 'STANDBY'}
|
|
||||||
</button>
|
|
||||||
{(['STANDARD', 'CONTEST', 'BROADCAST'] as const).map((m) => (
|
|
||||||
<button key={m} type="button" disabled={!st.connected}
|
|
||||||
onClick={() => AmpFanMode(amp.id, m).catch(() => {})}
|
|
||||||
className={cn('px-2.5 py-1.5 rounded-md border text-xs font-semibold disabled:opacity-40',
|
|
||||||
st.fan_mode === m ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted/30 border-border hover:bg-muted')}>
|
|
||||||
{m === 'STANDARD' ? t('flxp.fanStandard') : m === 'CONTEST' ? t('flxp.fanContest') : t('flxp.fanBroadcast')}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<span className="text-xs text-muted-foreground font-mono">{st.state || ''}{st.temperature ? ` · ${Math.round(st.temperature)}°C` : ''}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<button type="button" disabled={!st.connected}
|
|
||||||
onClick={doOperate}
|
|
||||||
className={cn('px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 transition-all disabled:opacity-40',
|
|
||||||
st.operate ? 'bg-warning text-warning-foreground border-warning' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
|
||||||
{st.operate ? 'OPERATE' : 'STANDBY'}
|
|
||||||
</button>
|
|
||||||
<div className="inline-flex rounded-lg overflow-hidden border border-success/70">
|
|
||||||
<button type="button"
|
|
||||||
disabled={isACOM ? !(st.port_open && st.transport === 'serial') : !st.connected}
|
|
||||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
|
||||||
className="px-2.5 py-1.5 text-xs font-bold bg-card text-success hover:bg-success/15 disabled:opacity-40">ON</button>
|
|
||||||
<button type="button" disabled={!st.connected}
|
|
||||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
|
||||||
className="px-2.5 py-1.5 text-xs font-bold bg-card text-danger border-l border-success/70 hover:bg-danger/15 disabled:opacity-40">OFF</button>
|
|
||||||
</div>
|
|
||||||
{!isACOM && (
|
|
||||||
<div className="inline-flex rounded-lg overflow-hidden border border-border">
|
|
||||||
{(['L', 'M', 'H'] as const).map((lvl, i) => (
|
|
||||||
<button key={lvl} type="button" disabled={!st.connected}
|
|
||||||
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
|
|
||||||
className={cn('px-2.5 py-1.5 text-xs font-bold disabled:opacity-40', i > 0 && 'border-l border-border',
|
|
||||||
(st.power_level || '').trim().toUpperCase() === lvl ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
|
||||||
{lvl}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs font-mono text-muted-foreground tabular-nums">
|
|
||||||
{st.connected
|
|
||||||
? <>
|
|
||||||
{isACOM ? (st.state || '') : (st.tx ? 'TX' : 'RX')}
|
|
||||||
{st.band ? ` · ${st.band}` : ''} · {outW}W · SWR {Number((isACOM ? st.swr : st.swr_ant) ?? 0).toFixed(1)} · {st.temp_c}°C
|
|
||||||
{isACOM && st.fan ? ` · Fan ${st.fan}` : ''}
|
|
||||||
</>
|
|
||||||
: (isACOM ? t('flxp.acomOffline') : t('flxp.speOffline'))}
|
|
||||||
</div>
|
|
||||||
{st.connected && (
|
|
||||||
<div>
|
|
||||||
<div className="h-2 rounded bg-muted overflow-hidden">
|
|
||||||
<div className={cn('h-full transition-all', frac > 0.9 ? 'bg-danger' : frac > 0.75 ? 'bg-warning' : 'bg-primary')}
|
|
||||||
style={{ width: `${Math.round(frac * 100)}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-muted-foreground mt-0.5">{t('flxp.outputPower')} — {outW} W / {maxW} W</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(st.err_text || st.warnings || st.alarms) && (
|
|
||||||
<div className="text-[11px] font-bold text-danger">⚠ {st.err_text || `${st.warnings || ''} ${st.alarms || ''}`}</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [devices, setDevices] = useState<Device[]>([]);
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
@@ -393,16 +263,24 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
|
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
|
||||||
});
|
});
|
||||||
const dragId = useRef<string | null>(null);
|
const dragId = useRef<string | null>(null);
|
||||||
|
// Max columns per row (persisted). "Auto" fills the window; a number caps the
|
||||||
|
// row so cards wrap onto further lines even when there's horizontal room.
|
||||||
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
||||||
// Amplifiers (Settings → Amplifier): shown here so operators without a
|
// Amplifiers (Settings → Amplifier): shown here so operators without a
|
||||||
// FlexRadio panel still get the controls. Re-read every 5s so enabling an
|
// FlexRadio panel still get the controls. EVERY configured amp gets its own
|
||||||
// amp in Settings makes the widget appear without reopening the tab.
|
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
|
||||||
const [ampCount, setAmpCount] = useState(0);
|
// FlexRadio meters read live; the Flex state rides along because a PGXL's
|
||||||
|
// OPERATE/meters come from the radio, not the direct GSCP link.
|
||||||
|
const [amps, setAmps] = useState<any[]>([]);
|
||||||
|
const [flexState, setFlexState] = useState<any>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const load = () => GetAmpStatuses().then((l: any) => { if (alive) setAmpCount((l ?? []).length); }).catch(() => {});
|
const load = () => Promise.all([
|
||||||
|
GetAmpStatuses().catch(() => []),
|
||||||
|
GetFlexState().catch(() => null),
|
||||||
|
]).then(([l, fx]: any[]) => { if (alive) { setAmps((l ?? []) as any[]); setFlexState(fx); } });
|
||||||
load();
|
load();
|
||||||
const id = window.setInterval(load, 5000);
|
const id = window.setInterval(load, 1500);
|
||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -491,25 +369,26 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
|
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
|
||||||
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
|
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 grid grid-cols-2 gap-2">
|
{/* Compact one-line relay buttons at a FIXED width so they don't stretch
|
||||||
|
across the whole card — they wrap to fill the available width instead. */}
|
||||||
|
<div className="p-2 flex flex-wrap gap-1.5">
|
||||||
{relays.map((r) => {
|
{relays.map((r) => {
|
||||||
const key = `${dev.id}:${r.number}`;
|
const key = `${dev.id}:${r.number}`;
|
||||||
const label = r.label || `${t('station.relay')} ${r.number}`;
|
const label = r.label || `${t('station.relay')} ${r.number}`;
|
||||||
return (
|
return (
|
||||||
<button key={r.number} type="button" disabled={!st?.connected}
|
<button key={r.number} type="button" disabled={!st?.connected}
|
||||||
|
title={label}
|
||||||
onClick={() => toggle(dev, r.number, !r.on)}
|
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',
|
className={cn('w-[150px] flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors disabled:opacity-40',
|
||||||
r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
|
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',
|
<span className={cn('flex items-center justify-center size-5 rounded shrink-0',
|
||||||
r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
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" />}
|
{busy[key] ? <Loader2 className="size-3 animate-spin" /> : <Power className="size-3" />}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0">
|
<span className="flex-1 min-w-0 text-xs font-medium truncate">{label}</span>
|
||||||
<span className="block text-xs font-medium truncate">{label}</span>
|
<span className={cn('text-[9px] font-bold shrink-0', r.on ? 'text-success' : 'text-muted-foreground/50')}>
|
||||||
<span className={cn('block text-[10px] font-semibold', r.on ? 'text-success' : 'text-muted-foreground')}>
|
|
||||||
{r.on ? t('station.on') : t('station.off')}
|
{r.on ? t('station.on') : t('station.off')}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -525,32 +404,25 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
if (ant.enabled) {
|
if (ant.enabled) {
|
||||||
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
||||||
}
|
}
|
||||||
if (ampCount > 0) {
|
// One card per configured amplifier (identical to the Flex panel's card).
|
||||||
widgets.push({ id: 'amplifier', node: <AmplifierWidget t={t} /> });
|
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} /> });
|
||||||
}
|
|
||||||
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
||||||
|
|
||||||
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||||||
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
||||||
const widgetIds = ordered.map((w) => w.id);
|
const widgetIds = ordered.map((w) => w.id);
|
||||||
|
|
||||||
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && ampCount === 0;
|
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && amps.length === 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 (
|
return (
|
||||||
<div className="flex-1 min-h-0 overflow-auto p-4">
|
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<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>
|
<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 gap-2">
|
||||||
|
{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
|
||||||
|
card can sit on a further line even with horizontal room to spare. */}
|
||||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
||||||
{(['auto', '2', '3', '4'] as const).map((c) => (
|
{(['auto', '1', '2', '3', '4'] as const).map((c) => (
|
||||||
<button key={c} type="button"
|
<button key={c} type="button"
|
||||||
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
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')}>
|
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||||
@@ -570,14 +442,25 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={cn('grid gap-3 items-start', gridCols[cols] ?? gridCols.auto)}>
|
{/* Dashboard of FIXED-WIDTH cards that wrap. "Auto" fills the window; a fixed
|
||||||
|
column count caps the container width so cards wrap onto more lines. Each
|
||||||
|
card has a grip handle (left rail) as the drag initiator (the card body is
|
||||||
|
full of buttons, so dragging the whole card was unreliable). */}
|
||||||
|
<div className="flex flex-wrap gap-4 items-start"
|
||||||
|
style={cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : undefined}>
|
||||||
{ordered.map((w) => (
|
{ordered.map((w) => (
|
||||||
<div key={w.id} draggable
|
<div key={w.id} className="flex items-stretch w-[430px]"
|
||||||
|
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
|
||||||
|
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
|
||||||
|
<div draggable
|
||||||
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
onDragEnd={() => { dragId.current = null; }}
|
||||||
onDrop={(e) => { e.preventDefault(); onDrop(w.id); }}
|
title={t('station.dragMove')}
|
||||||
className={cn('cursor-grab active:cursor-grabbing', dragId.current === w.id && 'opacity-60')}>
|
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
|
||||||
{w.node}
|
dragId.current === w.id && 'opacity-60')}>
|
||||||
|
<GripVertical className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">{w.node}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+131
-15
File diff suppressed because one or more lines are too long
@@ -73,16 +73,24 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
const load = () => {
|
const load = () => {
|
||||||
tries += 1;
|
tries += 1;
|
||||||
GetUIPref(LS_KEY).then((raw) => {
|
GetUIPref(LS_KEY).then((raw) => {
|
||||||
|
// Backend ANSWERED (settings store ready). Apply a valid stored value; an
|
||||||
|
// empty/invalid one means the theme is genuinely unset → keep the default.
|
||||||
|
// Either way we're done — do NOT retry (retrying only matters while the
|
||||||
|
// backend is still starting, which now surfaces as a rejected promise).
|
||||||
if (cancelled || userPicked.current) return;
|
if (cancelled || userPicked.current) return;
|
||||||
const v = raw as ThemeChoice;
|
const v = raw as ThemeChoice;
|
||||||
if (v && ALL.includes(v)) {
|
if (v && ALL.includes(v)) {
|
||||||
try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ }
|
try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ }
|
||||||
applyThemeToDom(v); // idempotent — safe to call unconditionally
|
applyThemeToDom(v); // idempotent — safe to call unconditionally
|
||||||
setThemeState(v);
|
setThemeState(v);
|
||||||
return; // restored
|
|
||||||
}
|
}
|
||||||
if (tries < 8) window.setTimeout(load, 300); // empty (unset or backend not ready yet) → retry
|
}).catch(() => {
|
||||||
}).catch(() => { if (!cancelled && tries < 8) window.setTimeout(load, 300); });
|
// Settings store not ready yet — a brief startup window before the backend
|
||||||
|
// has opened the local DB and built the store (the frontend can query it
|
||||||
|
// first). Keep retrying well past the old 2.4s cap so a dark theme isn't
|
||||||
|
// lost to the light default after an update cleared localStorage.
|
||||||
|
if (!cancelled && !userPicked.current && tries < 120) window.setTimeout(load, 300);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.20.9';
|
export const APP_VERSION = '0.21.0';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+29
-3
@@ -28,6 +28,8 @@ export function ACOMSetOperate(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function ACOMSetPower(arg1:boolean):Promise<void>;
|
export function ACOMSetPower(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function ADIFExtraFields():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
||||||
|
|
||||||
export function ADIFVersion():Promise<string>;
|
export function ADIFVersion():Promise<string>;
|
||||||
@@ -86,6 +88,8 @@ export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Prom
|
|||||||
|
|
||||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||||
|
|
||||||
|
export function CWDecoderRunning():Promise<boolean>;
|
||||||
|
|
||||||
export function ChatAvailable():Promise<boolean>;
|
export function ChatAvailable():Promise<boolean>;
|
||||||
|
|
||||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||||
@@ -162,6 +166,8 @@ export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||||
|
|
||||||
|
export function DownloadClublogMostWanted():Promise<main.ClublogMostWantedInfo>;
|
||||||
|
|
||||||
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 DownloadLoTWUsers():Promise<number>;
|
||||||
@@ -172,11 +178,11 @@ export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profil
|
|||||||
|
|
||||||
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
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,arg3:Array<string>):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,arg4:Array<string>):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>,arg4:Array<string>):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
export function ExportAward(arg1:string):Promise<string>;
|
export function ExportAward(arg1:string):Promise<string>;
|
||||||
|
|
||||||
@@ -366,6 +372,8 @@ export function GetCATSettings():Promise<main.CATSettings>;
|
|||||||
|
|
||||||
export function GetCATState():Promise<cat.RigState>;
|
export function GetCATState():Promise<cat.RigState>;
|
||||||
|
|
||||||
|
export function GetCWDecoderPitch():Promise<number>;
|
||||||
|
|
||||||
export function GetCatalogCodes():Promise<Array<string>>;
|
export function GetCatalogCodes():Promise<Array<string>>;
|
||||||
|
|
||||||
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
||||||
@@ -374,6 +382,8 @@ export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
|||||||
|
|
||||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||||
|
|
||||||
|
export function GetClublogMostWantedInfo():Promise<main.ClublogMostWantedInfo>;
|
||||||
|
|
||||||
export function GetClusterAutoConnect():Promise<boolean>;
|
export function GetClusterAutoConnect():Promise<boolean>;
|
||||||
|
|
||||||
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||||
@@ -736,6 +746,8 @@ export function RemovePassphrase(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function RenameDatabase(arg1:string):Promise<void>;
|
export function RenameDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function RenameLogbook(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>;
|
||||||
@@ -754,6 +766,8 @@ export function RestartQSORecorder():Promise<void>;
|
|||||||
|
|
||||||
export function RetryOfflineSync():Promise<number>;
|
export function RetryOfflineSync():Promise<number>;
|
||||||
|
|
||||||
|
export function RevealDataFolder():Promise<void>;
|
||||||
|
|
||||||
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>;
|
||||||
@@ -762,6 +776,8 @@ export function RotatorStop():Promise<void>;
|
|||||||
|
|
||||||
export function RunBackupNow():Promise<string>;
|
export function RunBackupNow():Promise<string>;
|
||||||
|
|
||||||
|
export function SMTPConfigured():Promise<boolean>;
|
||||||
|
|
||||||
export function SPESetOperate(arg1:boolean):Promise<void>;
|
export function SPESetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SPESetPower(arg1:boolean):Promise<void>;
|
export function SPESetPower(arg1:boolean):Promise<void>;
|
||||||
@@ -842,6 +858,8 @@ export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<voi
|
|||||||
|
|
||||||
export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
|
export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SendLogToDeveloper():Promise<void>;
|
||||||
|
|
||||||
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
||||||
@@ -850,8 +868,12 @@ 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 SetClublogMostWantedEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetCompactMode(arg1:boolean):Promise<void>;
|
export function SetCompactMode(arg1:boolean):Promise<void>;
|
||||||
@@ -866,8 +888,12 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function StartCWDecoder():Promise<void>;
|
||||||
|
|
||||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function StopCWDecoder():Promise<void>;
|
||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export function ACOMSetPower(arg1) {
|
|||||||
return window['go']['main']['App']['ACOMSetPower'](arg1);
|
return window['go']['main']['App']['ACOMSetPower'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ADIFExtraFields() {
|
||||||
|
return window['go']['main']['App']['ADIFExtraFields']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ADIFFields() {
|
export function ADIFFields() {
|
||||||
return window['go']['main']['App']['ADIFFields']();
|
return window['go']['main']['App']['ADIFFields']();
|
||||||
}
|
}
|
||||||
@@ -126,6 +130,10 @@ 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']();
|
||||||
}
|
}
|
||||||
@@ -278,6 +286,10 @@ export function DownloadClublogCty() {
|
|||||||
return window['go']['main']['App']['DownloadClublogCty']();
|
return window['go']['main']['App']['DownloadClublogCty']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadClublogMostWanted() {
|
||||||
|
return window['go']['main']['App']['DownloadClublogMostWanted']();
|
||||||
|
}
|
||||||
|
|
||||||
export function DownloadConfirmations(arg1, arg2, arg3) {
|
export function DownloadConfirmations(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
|
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
@@ -298,16 +310,16 @@ export function ExplainAward(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
|
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportADIF(arg1, arg2) {
|
export function ExportADIF(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
|
return window['go']['main']['App']['ExportADIF'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportADIFFiltered(arg1, arg2, arg3) {
|
export function ExportADIFFiltered(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['main']['App']['ExportADIFFiltered'](arg1, arg2, arg3);
|
return window['go']['main']['App']['ExportADIFFiltered'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportADIFSelected(arg1, arg2, arg3) {
|
export function ExportADIFSelected(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
|
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportAward(arg1) {
|
export function ExportAward(arg1) {
|
||||||
@@ -686,6 +698,10 @@ export function GetCATState() {
|
|||||||
return window['go']['main']['App']['GetCATState']();
|
return window['go']['main']['App']['GetCATState']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetCWDecoderPitch() {
|
||||||
|
return window['go']['main']['App']['GetCWDecoderPitch']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetCatalogCodes() {
|
export function GetCatalogCodes() {
|
||||||
return window['go']['main']['App']['GetCatalogCodes']();
|
return window['go']['main']['App']['GetCatalogCodes']();
|
||||||
}
|
}
|
||||||
@@ -702,6 +718,10 @@ export function GetClublogCtyInfo() {
|
|||||||
return window['go']['main']['App']['GetClublogCtyInfo']();
|
return window['go']['main']['App']['GetClublogCtyInfo']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetClublogMostWantedInfo() {
|
||||||
|
return window['go']['main']['App']['GetClublogMostWantedInfo']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetClusterAutoConnect() {
|
export function GetClusterAutoConnect() {
|
||||||
return window['go']['main']['App']['GetClusterAutoConnect']();
|
return window['go']['main']['App']['GetClusterAutoConnect']();
|
||||||
}
|
}
|
||||||
@@ -1426,6 +1446,10 @@ export function RenameDatabase(arg1) {
|
|||||||
return window['go']['main']['App']['RenameDatabase'](arg1);
|
return window['go']['main']['App']['RenameDatabase'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RenameLogbook(arg1) {
|
||||||
|
return window['go']['main']['App']['RenameLogbook'](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);
|
||||||
}
|
}
|
||||||
@@ -1462,6 +1486,10 @@ export function RetryOfflineSync() {
|
|||||||
return window['go']['main']['App']['RetryOfflineSync']();
|
return window['go']['main']['App']['RetryOfflineSync']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RevealDataFolder() {
|
||||||
|
return window['go']['main']['App']['RevealDataFolder']();
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -1478,6 +1506,10 @@ export function RunBackupNow() {
|
|||||||
return window['go']['main']['App']['RunBackupNow']();
|
return window['go']['main']['App']['RunBackupNow']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SMTPConfigured() {
|
||||||
|
return window['go']['main']['App']['SMTPConfigured']();
|
||||||
|
}
|
||||||
|
|
||||||
export function SPESetOperate(arg1) {
|
export function SPESetOperate(arg1) {
|
||||||
return window['go']['main']['App']['SPESetOperate'](arg1);
|
return window['go']['main']['App']['SPESetOperate'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1638,6 +1670,10 @@ export function SendEQSL(arg1, arg2, arg3) {
|
|||||||
return window['go']['main']['App']['SendEQSL'](arg1, arg2, arg3);
|
return window['go']['main']['App']['SendEQSL'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SendLogToDeveloper() {
|
||||||
|
return window['go']['main']['App']['SendLogToDeveloper']();
|
||||||
|
}
|
||||||
|
|
||||||
export function SendQSORecordingEmail(arg1) {
|
export function SendQSORecordingEmail(arg1) {
|
||||||
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1654,10 +1690,18 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetClublogMostWantedEnabled(arg1) {
|
||||||
|
return window['go']['main']['App']['SetClublogMostWantedEnabled'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetClusterAutoConnect(arg1) {
|
export function SetClusterAutoConnect(arg1) {
|
||||||
return window['go']['main']['App']['SetClusterAutoConnect'](arg1);
|
return window['go']['main']['App']['SetClusterAutoConnect'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1686,10 +1730,18 @@ export function SetUltrabeamDirection(arg1) {
|
|||||||
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function StartCWDecoder() {
|
||||||
|
return window['go']['main']['App']['StartCWDecoder']();
|
||||||
|
}
|
||||||
|
|
||||||
export function StationSetRelay(arg1, arg2, arg3) {
|
export function StationSetRelay(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function StopCWDecoder() {
|
||||||
|
return window['go']['main']['App']['StopCWDecoder']();
|
||||||
|
}
|
||||||
|
|
||||||
export function SwitchCATRig(arg1) {
|
export function SwitchCATRig(arg1) {
|
||||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1949,6 +1949,26 @@ export namespace main {
|
|||||||
this.count = source["count"];
|
this.count = source["count"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class ClublogMostWantedInfo {
|
||||||
|
enabled: boolean;
|
||||||
|
loaded: boolean;
|
||||||
|
callsign: string;
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ClublogMostWantedInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.loaded = source["loaded"];
|
||||||
|
this.callsign = source["callsign"];
|
||||||
|
this.date = source["date"];
|
||||||
|
this.count = source["count"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ContestBandRow {
|
export class ContestBandRow {
|
||||||
band: string;
|
band: string;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -2089,6 +2109,7 @@ export namespace main {
|
|||||||
path: string;
|
path: string;
|
||||||
default_path: string;
|
default_path: string;
|
||||||
is_custom: boolean;
|
is_custom: boolean;
|
||||||
|
logbook_default_path: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new DatabaseSettings(source);
|
return new DatabaseSettings(source);
|
||||||
@@ -2099,6 +2120,7 @@ export namespace main {
|
|||||||
this.path = source["path"];
|
this.path = source["path"];
|
||||||
this.default_path = source["default_path"];
|
this.default_path = source["default_path"];
|
||||||
this.is_custom = source["is_custom"];
|
this.is_custom = source["is_custom"];
|
||||||
|
this.logbook_default_path = source["logbook_default_path"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class DuplicateGroup {
|
export class DuplicateGroup {
|
||||||
@@ -2295,6 +2317,7 @@ export namespace main {
|
|||||||
user: string;
|
user: string;
|
||||||
password: string;
|
password: string;
|
||||||
database: string;
|
database: string;
|
||||||
|
sqlite_path?: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new MySQLSettings(source);
|
return new MySQLSettings(source);
|
||||||
@@ -2308,6 +2331,7 @@ export namespace main {
|
|||||||
this.user = source["user"];
|
this.user = source["user"];
|
||||||
this.password = source["password"];
|
this.password = source["password"];
|
||||||
this.database = source["database"];
|
this.database = source["database"];
|
||||||
|
this.sqlite_path = source["sqlite_path"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class OfflineStatus {
|
export class OfflineStatus {
|
||||||
@@ -2940,6 +2964,8 @@ export namespace main {
|
|||||||
follow: boolean;
|
follow: boolean;
|
||||||
step_khz: number;
|
step_khz: number;
|
||||||
tx_inhibit: boolean;
|
tx_inhibit: boolean;
|
||||||
|
freq_min_mhz: number;
|
||||||
|
freq_max_mhz: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new UltrabeamSettings(source);
|
return new UltrabeamSettings(source);
|
||||||
@@ -2957,6 +2983,8 @@ export namespace main {
|
|||||||
this.follow = source["follow"];
|
this.follow = source["follow"];
|
||||||
this.step_khz = source["step_khz"];
|
this.step_khz = source["step_khz"];
|
||||||
this.tx_inhibit = source["tx_inhibit"];
|
this.tx_inhibit = source["tx_inhibit"];
|
||||||
|
this.freq_min_mhz = source["freq_min_mhz"];
|
||||||
|
this.freq_max_mhz = source["freq_max_mhz"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class UltrabeamStatusInfo {
|
export class UltrabeamStatusInfo {
|
||||||
@@ -3035,6 +3063,9 @@ export namespace main {
|
|||||||
autospace: boolean;
|
autospace: boolean;
|
||||||
use_ptt: boolean;
|
use_ptt: boolean;
|
||||||
serial_echo: boolean;
|
serial_echo: boolean;
|
||||||
|
type: string;
|
||||||
|
cw_key_line: string;
|
||||||
|
cw_invert: boolean;
|
||||||
engine: string;
|
engine: string;
|
||||||
esc_clears_call: boolean;
|
esc_clears_call: boolean;
|
||||||
send_on_type: boolean;
|
send_on_type: boolean;
|
||||||
@@ -3061,6 +3092,9 @@ export namespace main {
|
|||||||
this.autospace = source["autospace"];
|
this.autospace = source["autospace"];
|
||||||
this.use_ptt = source["use_ptt"];
|
this.use_ptt = source["use_ptt"];
|
||||||
this.serial_echo = source["serial_echo"];
|
this.serial_echo = source["serial_echo"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.cw_key_line = source["cw_key_line"];
|
||||||
|
this.cw_invert = source["cw_invert"];
|
||||||
this.engine = source["engine"];
|
this.engine = source["engine"];
|
||||||
this.esc_clears_call = source["esc_clears_call"];
|
this.esc_clears_call = source["esc_clears_call"];
|
||||||
this.send_on_type = source["send_on_type"];
|
this.send_on_type = source["send_on_type"];
|
||||||
@@ -3335,6 +3369,7 @@ export namespace profile {
|
|||||||
user: string;
|
user: string;
|
||||||
password: string;
|
password: string;
|
||||||
database: string;
|
database: string;
|
||||||
|
path?: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ProfileDB(source);
|
return new ProfileDB(source);
|
||||||
@@ -3348,6 +3383,7 @@ export namespace profile {
|
|||||||
this.user = source["user"];
|
this.user = source["user"];
|
||||||
this.password = source["password"];
|
this.password = source["password"];
|
||||||
this.database = source["database"];
|
this.database = source["database"];
|
||||||
|
this.path = source["path"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class Profile {
|
export class Profile {
|
||||||
@@ -4219,6 +4255,7 @@ export namespace qso {
|
|||||||
dxcc_bands: string[];
|
dxcc_bands: string[];
|
||||||
dxcc_modes: string[];
|
dxcc_modes: string[];
|
||||||
dxcc_band_modes: BandMode[];
|
dxcc_band_modes: BandMode[];
|
||||||
|
mw_rank?: number;
|
||||||
band_status: BandStatus[];
|
band_status: BandStatus[];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
@@ -4243,6 +4280,7 @@ export namespace qso {
|
|||||||
this.dxcc_bands = source["dxcc_bands"];
|
this.dxcc_bands = source["dxcc_bands"];
|
||||||
this.dxcc_modes = source["dxcc_modes"];
|
this.dxcc_modes = source["dxcc_modes"];
|
||||||
this.dxcc_band_modes = this.convertValues(source["dxcc_band_modes"], BandMode);
|
this.dxcc_band_modes = this.convertValues(source["dxcc_band_modes"], BandMode);
|
||||||
|
this.mw_rank = source["mw_rank"];
|
||||||
this.band_status = this.convertValues(source["band_status"], BandStatus);
|
this.band_status = this.convertValues(source["band_status"], BandStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+162
-131
@@ -34,6 +34,12 @@ type Exporter struct {
|
|||||||
// export destined for another logger; set true for a full OpsLog→OpsLog
|
// export destined for another logger; set true for a full OpsLog→OpsLog
|
||||||
// round-trip that preserves everything.
|
// round-trip that preserves everything.
|
||||||
IncludeAppFields bool
|
IncludeAppFields bool
|
||||||
|
|
||||||
|
// Fields, when non-nil, restricts the export to exactly these ADIF tags
|
||||||
|
// (uppercase) — the "choose which fields to export" mode. nil = write every
|
||||||
|
// field (the standard/full behaviour above). When set it overrides
|
||||||
|
// IncludeAppFields: an APP_/vendor tag is written iff it's in the set.
|
||||||
|
Fields map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// iterator streams QSOs through fn. The three concrete sources (all, filtered,
|
// iterator streams QSOs through fn. The three concrete sources (all, filtered,
|
||||||
@@ -100,7 +106,7 @@ func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (in
|
|||||||
|
|
||||||
count := 0
|
count := 0
|
||||||
err := iter(ctx, func(q qso.QSO) error {
|
err := iter(ctx, func(q qso.QSO) error {
|
||||||
writeRecord(bw, q, e.IncludeAppFields)
|
writeRecord(bw, q, e.IncludeAppFields, e.Fields)
|
||||||
count++
|
count++
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -115,7 +121,7 @@ func SingleRecordADIF(q qso.QSO) string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
bw := bufio.NewWriter(&b)
|
bw := bufio.NewWriter(&b)
|
||||||
// Uploads target other services — keep it standard (no app-specific tags).
|
// Uploads target other services — keep it standard (no app-specific tags).
|
||||||
writeRecord(bw, q, false)
|
writeRecord(bw, q, false, nil)
|
||||||
bw.Flush()
|
bw.Flush()
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
@@ -127,7 +133,7 @@ func SingleRecordADIF(q qso.QSO) string {
|
|||||||
func FullRecordADIF(q qso.QSO) string {
|
func FullRecordADIF(q qso.QSO) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
bw := bufio.NewWriter(&b)
|
bw := bufio.NewWriter(&b)
|
||||||
writeRecord(bw, q, true)
|
writeRecord(bw, q, true, nil)
|
||||||
bw.Flush()
|
bw.Flush()
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
@@ -155,161 +161,181 @@ func BatchRecordsADIF(records []string) string {
|
|||||||
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
||||||
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
||||||
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
||||||
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
|
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool) {
|
||||||
|
// allow == nil → write every promoted field (standard/full behaviour).
|
||||||
|
// Otherwise a promoted tag is written only when it's in the chosen set.
|
||||||
|
// w/wi/wf wrap the raw writers with that gate so the ~150 field lines below
|
||||||
|
// stay one-liners.
|
||||||
|
ok := func(tag string) bool { return allow == nil || allow[tag] }
|
||||||
|
w := func(tag, v string) {
|
||||||
|
if ok(tag) {
|
||||||
|
writeField(bw, tag, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wi := func(tag string, p *int) {
|
||||||
|
if ok(tag) {
|
||||||
|
writeIntPtr(bw, tag, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wf := func(tag string, p *float64, decimals int) {
|
||||||
|
if ok(tag) {
|
||||||
|
writeFloatPtr(bw, tag, p, decimals)
|
||||||
|
}
|
||||||
|
}
|
||||||
// --- Core ---
|
// --- Core ---
|
||||||
writeField(bw, "CALL", q.Callsign)
|
w("CALL", q.Callsign)
|
||||||
|
|
||||||
if !q.QSODate.IsZero() {
|
if !q.QSODate.IsZero() {
|
||||||
writeField(bw, "QSO_DATE", q.QSODate.UTC().Format("20060102"))
|
w("QSO_DATE", q.QSODate.UTC().Format("20060102"))
|
||||||
writeField(bw, "TIME_ON", q.QSODate.UTC().Format("150405"))
|
w("TIME_ON", q.QSODate.UTC().Format("150405"))
|
||||||
}
|
}
|
||||||
if !q.QSODateOff.IsZero() {
|
if !q.QSODateOff.IsZero() {
|
||||||
writeField(bw, "QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102"))
|
w("QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102"))
|
||||||
writeField(bw, "TIME_OFF", q.QSODateOff.UTC().Format("150405"))
|
w("TIME_OFF", q.QSODateOff.UTC().Format("150405"))
|
||||||
}
|
}
|
||||||
writeField(bw, "BAND", q.Band)
|
w("BAND", q.Band)
|
||||||
writeField(bw, "BAND_RX", q.BandRX)
|
w("BAND_RX", q.BandRX)
|
||||||
|
|
||||||
mode, submode := modeForExport(q.Mode, q.Submode)
|
mode, submode := modeForExport(q.Mode, q.Submode)
|
||||||
writeField(bw, "MODE", mode)
|
w("MODE", mode)
|
||||||
writeField(bw, "SUBMODE", submode)
|
w("SUBMODE", submode)
|
||||||
|
|
||||||
if q.FreqHz != nil && *q.FreqHz > 0 {
|
if q.FreqHz != nil && *q.FreqHz > 0 {
|
||||||
writeField(bw, "FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
|
w("FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
|
||||||
}
|
}
|
||||||
if q.FreqRXHz != nil && *q.FreqRXHz > 0 {
|
if q.FreqRXHz != nil && *q.FreqRXHz > 0 {
|
||||||
writeField(bw, "FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64))
|
w("FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64))
|
||||||
}
|
}
|
||||||
|
|
||||||
writeField(bw, "RST_SENT", q.RSTSent)
|
w("RST_SENT", q.RSTSent)
|
||||||
writeField(bw, "RST_RCVD", q.RSTRcvd)
|
w("RST_RCVD", q.RSTRcvd)
|
||||||
|
|
||||||
// --- Contacted ---
|
// --- Contacted ---
|
||||||
writeField(bw, "NAME", q.Name)
|
w("NAME", q.Name)
|
||||||
writeField(bw, "QTH", q.QTH)
|
w("QTH", q.QTH)
|
||||||
writeField(bw, "ADDRESS", q.Address)
|
w("ADDRESS", q.Address)
|
||||||
writeField(bw, "EMAIL", q.Email)
|
w("EMAIL", q.Email)
|
||||||
writeField(bw, "WEB", q.Web)
|
w("WEB", q.Web)
|
||||||
writeField(bw, "GRIDSQUARE", q.Grid)
|
w("GRIDSQUARE", q.Grid)
|
||||||
writeField(bw, "GRIDSQUARE_EXT", q.GridExt)
|
w("GRIDSQUARE_EXT", q.GridExt)
|
||||||
writeField(bw, "VUCC_GRIDS", q.VUCCGrids)
|
w("VUCC_GRIDS", q.VUCCGrids)
|
||||||
writeField(bw, "COUNTRY", q.Country)
|
w("COUNTRY", q.Country)
|
||||||
writeField(bw, "STATE", q.State)
|
w("STATE", q.State)
|
||||||
writeField(bw, "CNTY", q.County)
|
w("CNTY", q.County)
|
||||||
writeIntPtr(bw, "DXCC", q.DXCC)
|
wi("DXCC", q.DXCC)
|
||||||
writeField(bw, "CONT", q.Continent)
|
w("CONT", q.Continent)
|
||||||
writeIntPtr(bw, "CQZ", q.CQZ)
|
wi("CQZ", q.CQZ)
|
||||||
writeIntPtr(bw, "ITUZ", q.ITUZ)
|
wi("ITUZ", q.ITUZ)
|
||||||
writeField(bw, "IOTA", q.IOTA)
|
w("IOTA", q.IOTA)
|
||||||
writeField(bw, "SOTA_REF", q.SOTARef)
|
w("SOTA_REF", q.SOTARef)
|
||||||
writeField(bw, "POTA_REF", q.POTARef)
|
w("POTA_REF", q.POTARef)
|
||||||
writeIntPtr(bw, "AGE", q.Age)
|
wi("AGE", q.Age)
|
||||||
writeFloatPtr(bw, "LAT", q.Lat, 6)
|
wf("LAT", q.Lat, 6)
|
||||||
writeFloatPtr(bw, "LON", q.Lon, 6)
|
wf("LON", q.Lon, 6)
|
||||||
writeField(bw, "RIG", q.Rig)
|
w("RIG", q.Rig)
|
||||||
writeField(bw, "ANT", q.Ant)
|
w("ANT", q.Ant)
|
||||||
|
|
||||||
// --- QSL / LoTW / eQSL / Clublog / HRDLog ---
|
// --- QSL / LoTW / eQSL / Clublog / HRDLog ---
|
||||||
writeField(bw, "QSL_SENT", q.QSLSent)
|
w("QSL_SENT", q.QSLSent)
|
||||||
writeField(bw, "QSL_RCVD", q.QSLRcvd)
|
w("QSL_RCVD", q.QSLRcvd)
|
||||||
writeField(bw, "QSLSDATE", q.QSLSentDate)
|
w("QSLSDATE", q.QSLSentDate)
|
||||||
writeField(bw, "QSLRDATE", q.QSLRcvdDate)
|
w("QSLRDATE", q.QSLRcvdDate)
|
||||||
writeField(bw, "QSL_VIA", q.QSLVia)
|
w("QSL_VIA", q.QSLVia)
|
||||||
writeField(bw, "QSLMSG", q.QSLMsg)
|
w("QSLMSG", q.QSLMsg)
|
||||||
writeField(bw, "QSLMSG_RCVD", q.QSLMsgRcvd)
|
w("QSLMSG_RCVD", q.QSLMsgRcvd)
|
||||||
writeField(bw, "LOTW_QSL_SENT", q.LOTWSent)
|
w("LOTW_QSL_SENT", q.LOTWSent)
|
||||||
writeField(bw, "LOTW_QSL_RCVD", q.LOTWRcvd)
|
w("LOTW_QSL_RCVD", q.LOTWRcvd)
|
||||||
writeField(bw, "LOTW_QSLSDATE", q.LOTWSentDate)
|
w("LOTW_QSLSDATE", q.LOTWSentDate)
|
||||||
writeField(bw, "LOTW_QSLRDATE", q.LOTWRcvdDate)
|
w("LOTW_QSLRDATE", q.LOTWRcvdDate)
|
||||||
writeField(bw, "EQSL_QSL_SENT", q.EQSLSent)
|
w("EQSL_QSL_SENT", q.EQSLSent)
|
||||||
writeField(bw, "EQSL_QSL_RCVD", q.EQSLRcvd)
|
w("EQSL_QSL_RCVD", q.EQSLRcvd)
|
||||||
writeField(bw, "EQSL_QSLSDATE", q.EQSLSentDate)
|
w("EQSL_QSLSDATE", q.EQSLSentDate)
|
||||||
writeField(bw, "EQSL_QSLRDATE", q.EQSLRcvdDate)
|
w("EQSL_QSLRDATE", q.EQSLRcvdDate)
|
||||||
writeField(bw, "CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate)
|
w("CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate)
|
||||||
writeField(bw, "CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
|
w("CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
|
||||||
writeField(bw, "HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
|
w("HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
|
||||||
writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
|
w("HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
|
||||||
writeField(bw, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
|
w("QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
|
||||||
writeField(bw, "QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
||||||
writeField(bw, "QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
||||||
writeField(bw, "QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
||||||
|
|
||||||
// --- Contest ---
|
// --- Contest ---
|
||||||
writeField(bw, "CONTEST_ID", q.ContestID)
|
w("CONTEST_ID", q.ContestID)
|
||||||
writeIntPtr(bw, "SRX", q.SRX)
|
wi("SRX", q.SRX)
|
||||||
writeIntPtr(bw, "STX", q.STX)
|
wi("STX", q.STX)
|
||||||
writeField(bw, "SRX_STRING", q.SRXString)
|
w("SRX_STRING", q.SRXString)
|
||||||
writeField(bw, "STX_STRING", q.STXString)
|
w("STX_STRING", q.STXString)
|
||||||
writeField(bw, "CHECK", q.Check)
|
w("CHECK", q.Check)
|
||||||
writeField(bw, "PRECEDENCE", q.Precedence)
|
w("PRECEDENCE", q.Precedence)
|
||||||
writeField(bw, "ARRL_SECT", q.ARRLSect)
|
w("ARRL_SECT", q.ARRLSect)
|
||||||
|
|
||||||
// --- Satellite / propagation ---
|
// --- Satellite / propagation ---
|
||||||
writeField(bw, "PROP_MODE", q.PropMode)
|
w("PROP_MODE", q.PropMode)
|
||||||
writeField(bw, "SAT_NAME", q.SatName)
|
w("SAT_NAME", q.SatName)
|
||||||
writeField(bw, "SAT_MODE", q.SatMode)
|
w("SAT_MODE", q.SatMode)
|
||||||
writeFloatPtr(bw, "ANT_AZ", q.AntAz, 1)
|
wf("ANT_AZ", q.AntAz, 1)
|
||||||
writeFloatPtr(bw, "ANT_EL", q.AntEl, 1)
|
wf("ANT_EL", q.AntEl, 1)
|
||||||
writeField(bw, "ANT_PATH", q.AntPath)
|
w("ANT_PATH", q.AntPath)
|
||||||
|
|
||||||
// --- My station / operator ---
|
// --- My station / operator ---
|
||||||
writeField(bw, "STATION_CALLSIGN", q.StationCallsign)
|
w("STATION_CALLSIGN", q.StationCallsign)
|
||||||
writeField(bw, "OPERATOR", q.Operator)
|
w("OPERATOR", q.Operator)
|
||||||
writeField(bw, "MY_GRIDSQUARE", q.MyGrid)
|
w("MY_GRIDSQUARE", q.MyGrid)
|
||||||
writeField(bw, "MY_GRIDSQUARE_EXT", q.MyGridExt)
|
w("MY_GRIDSQUARE_EXT", q.MyGridExt)
|
||||||
writeField(bw, "MY_COUNTRY", q.MyCountry)
|
w("MY_COUNTRY", q.MyCountry)
|
||||||
writeField(bw, "MY_STATE", q.MyState)
|
w("MY_STATE", q.MyState)
|
||||||
writeField(bw, "MY_CNTY", q.MyCounty)
|
w("MY_CNTY", q.MyCounty)
|
||||||
writeField(bw, "MY_IOTA", q.MyIOTA)
|
w("MY_IOTA", q.MyIOTA)
|
||||||
writeField(bw, "MY_SOTA_REF", q.MySOTARef)
|
w("MY_SOTA_REF", q.MySOTARef)
|
||||||
writeField(bw, "MY_POTA_REF", q.MyPOTARef)
|
w("MY_POTA_REF", q.MyPOTARef)
|
||||||
writeIntPtr(bw, "MY_DXCC", q.MyDXCC)
|
wi("MY_DXCC", q.MyDXCC)
|
||||||
writeIntPtr(bw, "MY_CQ_ZONE", q.MyCQZone)
|
wi("MY_CQ_ZONE", q.MyCQZone)
|
||||||
writeIntPtr(bw, "MY_ITU_ZONE", q.MyITUZone)
|
wi("MY_ITU_ZONE", q.MyITUZone)
|
||||||
writeFloatPtr(bw, "MY_LAT", q.MyLat, 6)
|
wf("MY_LAT", q.MyLat, 6)
|
||||||
writeFloatPtr(bw, "MY_LON", q.MyLon, 6)
|
wf("MY_LON", q.MyLon, 6)
|
||||||
writeField(bw, "MY_STREET", q.MyStreet)
|
w("MY_STREET", q.MyStreet)
|
||||||
writeField(bw, "MY_CITY", q.MyCity)
|
w("MY_CITY", q.MyCity)
|
||||||
writeField(bw, "MY_POSTAL_CODE", q.MyPostalCode)
|
w("MY_POSTAL_CODE", q.MyPostalCode)
|
||||||
writeField(bw, "MY_RIG", q.MyRig)
|
w("MY_RIG", q.MyRig)
|
||||||
writeField(bw, "MY_ANTENNA", q.MyAntenna)
|
w("MY_ANTENNA", q.MyAntenna)
|
||||||
|
|
||||||
// --- Misc ---
|
// --- Misc ---
|
||||||
writeFloatPtr(bw, "TX_PWR", q.TXPower, 1)
|
wf("TX_PWR", q.TXPower, 1)
|
||||||
writeField(bw, "COMMENT", q.Comment)
|
w("COMMENT", q.Comment)
|
||||||
writeField(bw, "NOTES", q.Notes)
|
w("NOTES", q.Notes)
|
||||||
|
|
||||||
// --- ADIF 3.1.7 additional promoted fields ---
|
// --- ADIF 3.1.7 additional promoted fields ---
|
||||||
writeField(bw, "SIG", q.SIG)
|
w("SIG", q.SIG)
|
||||||
writeField(bw, "SIG_INFO", q.SIGInfo)
|
w("SIG_INFO", q.SIGInfo)
|
||||||
writeField(bw, "MY_SIG", q.MySIG)
|
w("MY_SIG", q.MySIG)
|
||||||
writeField(bw, "MY_SIG_INFO", q.MySIGInfo)
|
w("MY_SIG_INFO", q.MySIGInfo)
|
||||||
writeField(bw, "WWFF_REF", q.WWFFRef)
|
w("WWFF_REF", q.WWFFRef)
|
||||||
writeField(bw, "MY_WWFF_REF", q.MyWWFFRef)
|
w("MY_WWFF_REF", q.MyWWFFRef)
|
||||||
writeFloatPtr(bw, "DISTANCE", q.Distance, 1)
|
wf("DISTANCE", q.Distance, 1)
|
||||||
writeFloatPtr(bw, "RX_PWR", q.RXPower, 1)
|
wf("RX_PWR", q.RXPower, 1)
|
||||||
writeFloatPtr(bw, "A_INDEX", q.AIndex, 0)
|
wf("A_INDEX", q.AIndex, 0)
|
||||||
writeFloatPtr(bw, "K_INDEX", q.KIndex, 0)
|
wf("K_INDEX", q.KIndex, 0)
|
||||||
writeFloatPtr(bw, "SFI", q.SFI, 0)
|
wf("SFI", q.SFI, 0)
|
||||||
writeField(bw, "SKCC", q.SKCC)
|
w("SKCC", q.SKCC)
|
||||||
writeField(bw, "FISTS", q.FISTS)
|
w("FISTS", q.FISTS)
|
||||||
writeField(bw, "TEN_TEN", q.TenTen)
|
w("TEN_TEN", q.TenTen)
|
||||||
writeField(bw, "CONTACTED_OP", q.ContactedOp)
|
w("CONTACTED_OP", q.ContactedOp)
|
||||||
writeField(bw, "EQ_CALL", q.EqCall)
|
w("EQ_CALL", q.EqCall)
|
||||||
writeField(bw, "PFX", q.PFX)
|
w("PFX", q.PFX)
|
||||||
writeField(bw, "MY_NAME", q.MyName)
|
w("MY_NAME", q.MyName)
|
||||||
writeField(bw, "CLASS", q.Class)
|
w("CLASS", q.Class)
|
||||||
writeField(bw, "DARC_DOK", q.DarcDOK)
|
w("DARC_DOK", q.DarcDOK)
|
||||||
writeField(bw, "MY_DARC_DOK", q.MyDarcDOK)
|
w("MY_DARC_DOK", q.MyDarcDOK)
|
||||||
writeField(bw, "REGION", q.Region)
|
w("REGION", q.Region)
|
||||||
writeField(bw, "SILENT_KEY", q.SilentKey)
|
w("SILENT_KEY", q.SilentKey)
|
||||||
writeField(bw, "SWL", q.SWL)
|
w("SWL", q.SWL)
|
||||||
writeField(bw, "QSO_COMPLETE", q.QSOComplete)
|
w("QSO_COMPLETE", q.QSOComplete)
|
||||||
writeField(bw, "QSO_RANDOM", q.QSORandom)
|
w("QSO_RANDOM", q.QSORandom)
|
||||||
writeField(bw, "CREDIT_GRANTED", q.CreditGranted)
|
w("CREDIT_GRANTED", q.CreditGranted)
|
||||||
writeField(bw, "CREDIT_SUBMITTED", q.CreditSubmitted)
|
w("CREDIT_SUBMITTED", q.CreditSubmitted)
|
||||||
writeField(bw, "MY_ARRL_SECT", q.MyARRLSect)
|
w("MY_ARRL_SECT", q.MyARRLSect)
|
||||||
writeField(bw, "MY_VUCC_GRIDS", q.MyVUCCGrids)
|
w("MY_VUCC_GRIDS", q.MyVUCCGrids)
|
||||||
|
|
||||||
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
|
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
|
||||||
// Standard mode emits ONLY valid ADIF-spec fields, so it drops APP_*
|
// Standard mode emits ONLY valid ADIF-spec fields, so it drops APP_*
|
||||||
@@ -318,7 +344,12 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
|
|||||||
// extra for a lossless OpsLog round-trip.
|
// extra for a lossless OpsLog round-trip.
|
||||||
for k, v := range q.Extras {
|
for k, v := range q.Extras {
|
||||||
tag := strings.ToUpper(k)
|
tag := strings.ToUpper(k)
|
||||||
if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
|
if allow != nil {
|
||||||
|
// Chosen-fields mode: the set alone decides (incl. APP_/vendor tags).
|
||||||
|
if !allow[tag] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
writeField(bw, tag, v)
|
writeField(bw, tag, v)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestPromotedFieldsRoundTrip(t *testing.T) {
|
|||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
bw := bufio.NewWriter(&buf)
|
bw := bufio.NewWriter(&buf)
|
||||||
bw.WriteString("<EOH>\n")
|
bw.WriteString("<EOH>\n")
|
||||||
writeRecord(bw, in, true)
|
writeRecord(bw, in, true, nil)
|
||||||
bw.Flush()
|
bw.Flush()
|
||||||
|
|
||||||
var rec Record
|
var rec Record
|
||||||
@@ -115,7 +115,7 @@ func TestStandardExportDropsNonStandard(t *testing.T) {
|
|||||||
func renderRecord(q qso.QSO, includeApp bool) string {
|
func renderRecord(q qso.QSO, includeApp bool) string {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
bw := bufio.NewWriter(&buf)
|
bw := bufio.NewWriter(&buf)
|
||||||
writeRecord(bw, q, includeApp)
|
writeRecord(bw, q, includeApp, nil)
|
||||||
bw.Flush()
|
bw.Flush()
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "H26",
|
||||||
|
"name": "The Helvetia 26 Award HF",
|
||||||
|
"description": "26 Cantons of Switzerland",
|
||||||
|
"valid": true,
|
||||||
|
"url": "https://uska.ch/en/contest/uska-diplome/",
|
||||||
|
"ref_url": "https://uska.ch/en/contest/uska-diplome/",
|
||||||
|
"valid_from": "1980-01-01",
|
||||||
|
"valid_to": "9999-12-01",
|
||||||
|
"ref_display": "name",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "description",
|
||||||
|
"pattern": "",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "description"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dxcc_filter": [
|
||||||
|
287
|
||||||
|
],
|
||||||
|
"valid_bands": [
|
||||||
|
"2190m",
|
||||||
|
"630m",
|
||||||
|
"160m",
|
||||||
|
"80m",
|
||||||
|
"40m",
|
||||||
|
"30m",
|
||||||
|
"20m",
|
||||||
|
"17m",
|
||||||
|
"15m",
|
||||||
|
"12m",
|
||||||
|
"10m"
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true,
|
||||||
|
"version": 3
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "AG",
|
||||||
|
"name": "Aargau",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Aarau|Baden|Wettingen|Wohlen|Rheinfelden|Zofingen|Lenzburg|Brugg|Oftringen|Suhr|Spreitenbach)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AI",
|
||||||
|
"name": "Appenzell Innerrhoden",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Appenzell|Oberegg)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AR",
|
||||||
|
"name": "Appenzell Ausserrhoden",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Herisau|Teufen|Speicher|Heiden|Gais|Urnäsch|Waldstatt)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BE",
|
||||||
|
"name": "Bern",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Bern|Berne|Thun|Biel|Bienne|Köniz|Burgdorf|Langenthal|Steffisburg|Münsingen|Spiez|Interlaken|Ostermundigen|Lyss|Zollikofen)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BL",
|
||||||
|
"name": "Basel-Landschaft",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Liestal|Allschwil|Reinach|Muttenz|Pratteln|Binningen|Münchenstein|Birsfelden|Aesch|Sissach|Oberwil)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BS",
|
||||||
|
"name": "Basel-Stadt",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Basel|Bâle|Bale|Riehen|Bettingen)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FR",
|
||||||
|
"name": "Fribourg",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Fribourg|Freiburg|Bulle|Marly|Düdingen|Estavayer|Murten|Morat|Villars-sur-Glâne|Guin)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GE",
|
||||||
|
"name": "Geneva",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Genève|Geneve|Sezenove|Chene-Bourg|Chene Bourg|Geneva|Genf|Carouge|Vernier|Lancy|Meyrin|Onex|Thônex|Thonex|Versoix|Bernex|Plan-les-Ouates)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GL",
|
||||||
|
"name": "Glarus",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Glarus|Glaris|Näfels|Netstal|Ennenda|Mollis)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GR",
|
||||||
|
"name": "Graubünden (Grisons)",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Chur|Coire|Kueblis|Davos|Sankt Moritz|St\\.? Moritz|Landquart|Arosa|Klosters|Ilanz|Thusis|Poschiavo|Domat|Ems)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "JU",
|
||||||
|
"name": "Jura",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Delémont|Delemont|Porrentruy|Bassecourt|Courroux|Saignelégier|Alle)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LU",
|
||||||
|
"name": "Lucerne",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Luzern|Lucerne|Emmen|Kriens|Horw|Ebikon|Sursee|Hochdorf|Willisau|Rothenburg)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NE",
|
||||||
|
"name": "Neuchâtel",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Neuchâtel|Neuchatel|Neuenburg|La Chaux-de-Fonds|Chaux-de-Fonds|Le Locle|Peseux|Boudry|Colombier|Marin)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NW",
|
||||||
|
"name": "Nidwalden",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Stans|Hergiswil|Buochs|Stansstad|Beckenried|Ennetbürgen)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "OW",
|
||||||
|
"name": "Obwalden",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Sarnen|Kerns|Alpnach|Engelberg|Sachseln|Giswil)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SG",
|
||||||
|
"name": "St. Gallen",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Sankt Gallen|Saint-Gall|Rapperswil|Wil|Gossau|Rorschach|Uzwil|Flawil|Altstätten|Jona)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SH",
|
||||||
|
"name": "Schaffhausen",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Schaffhausen|Schaffhouse|Neuhausen|Stein am Rhein|Thayngen)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SO",
|
||||||
|
"name": "Solothurn",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Solothurn|Soleure|Olten|Grenchen|Zuchwil|Dornach|Oensingen|Balsthal|Biberist)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SZ",
|
||||||
|
"name": "Schwyz",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Schwyz|Einsiedeln|Freienbach|Küssnacht|Arth|Lachen|Wollerau|Brunnen|Goldau)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TG",
|
||||||
|
"name": "Thurgau",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Frauenfeld|Kreuzlingen|Arbon|Amriswil|Weinfelden|Romanshorn|Sirnach|Aadorf|Münchwilen)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TI",
|
||||||
|
"name": "Ticino",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Lugano|Bellinzona|Locarno|Mendrisio|Chiasso|Biasca|Ascona|Losone|Minusio|Giubiasco|Massagno)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "UR",
|
||||||
|
"name": "Uri",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Altdorf|Schattdorf|Erstfeld|Andermatt|Flüelen|Bürglen)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VD",
|
||||||
|
"name": "Vaud",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Lausanne|Chavornay|Blonay|Saint-Cergue|St-Cergue|St Cergue|Yverdon|Gingins|Montreux|Nyon|Vevey|Renens|Morges|Gland|Pully|Prilly|Ecublens|Aigle|Payerne|Rolle|Lutry|Bussigny)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VS",
|
||||||
|
"name": "Valais",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Sion|Sitten|Martigny|Monthey|Sierre|Brig|Brigue|Naters|Visp|Viège|Conthey|Fully|Verbier|Zermatt|Crans-Montana|Saas-Fee|Savièse|Bagnes)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ZG",
|
||||||
|
"name": "Zug",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Zug|Zoug|Baar|Cham|Steinhausen|Risch|Rotkreuz|Hünenberg|Unterägeri|Oberägeri)\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ZH",
|
||||||
|
"name": "Zurich",
|
||||||
|
"dxcc": 287,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Zürich|Zurich|Winterthur|Uster|Dübendorf|Dietikon|Wetzikon|Kloten|Wädenswil|Horgen|Thalwil|Opfikon|Küsnacht|Meilen|Bülach|Regensdorf|Schlieren|Adliswil|Volketswil)\\b",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+41
-9
@@ -649,6 +649,18 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
}
|
}
|
||||||
if removed {
|
if removed {
|
||||||
f.amp = flexAmp{}
|
f.amp = flexAmp{}
|
||||||
|
// The amp's meters (FWD / ID / TEMP …) don't always get an individual
|
||||||
|
// "meter removed" push, so power-cycling the amp left the OLD meter
|
||||||
|
// ids in the maps while the re-registered amp added NEW ones — the
|
||||||
|
// panel then showed every amp meter twice. Drop every AMP-sourced
|
||||||
|
// meter here so only the fresh set survives.
|
||||||
|
for id, mi := range f.meterMeta {
|
||||||
|
if strings.Contains(strings.ToUpper(mi.src), "AMP") {
|
||||||
|
delete(f.meterMeta, id)
|
||||||
|
delete(f.meterVal, id)
|
||||||
|
delete(f.meterSub, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
}
|
}
|
||||||
@@ -660,6 +672,26 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
f.meterRawLogged = true
|
f.meterRawLogged = true
|
||||||
debugLog.Printf("Flex: meter status raw: %s", payload)
|
debugLog.Printf("Flex: meter status raw: %s", payload)
|
||||||
}
|
}
|
||||||
|
// Meter removal — "meter <id> removed": drop it so a stale value can't
|
||||||
|
// linger (e.g. after an amp/slice is torn down).
|
||||||
|
removedMeter := false
|
||||||
|
for _, tok := range fields[1:] {
|
||||||
|
if tok == "removed" || tok == "in_use=0" {
|
||||||
|
removedMeter = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if removedMeter {
|
||||||
|
f.mu.Lock()
|
||||||
|
for _, tok := range fields[1:] {
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(tok)); err == nil {
|
||||||
|
delete(f.meterMeta, n)
|
||||||
|
delete(f.meterVal, n)
|
||||||
|
delete(f.meterSub, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
var newIDs []int
|
var newIDs []int
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
for _, tok := range fields[1:] {
|
for _, tok := range fields[1:] {
|
||||||
@@ -1162,7 +1194,11 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
|||||||
}
|
}
|
||||||
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
|
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())
|
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
|
||||||
if m := flexEncode(s.Mode); m != "" {
|
// Convert to a real Flex mode (USB/LSB/CW/DIGU/…): SmartSDR only switches the
|
||||||
|
// slice mode on a spot click when mode= is one of ITS mode names — "SSB" or a
|
||||||
|
// bare cluster label is ignored, so the mode wouldn't change. adifModeToFlex
|
||||||
|
// also resolves SSB→USB/LSB from the spot frequency.
|
||||||
|
if m := flexEncode(adifModeToFlex(s.Mode, s.FreqHz)); m != "" {
|
||||||
cmd += " mode=" + m
|
cmd += " mode=" + m
|
||||||
}
|
}
|
||||||
if c := flexEncode(s.Comment); c != "" {
|
if c := flexEncode(s.Comment); c != "" {
|
||||||
@@ -1853,15 +1889,11 @@ func (f *Flex) SetTune(on bool) error {
|
|||||||
// SmartSDR refuses to transmit while inhibit is set, so it holds even against a
|
// 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.
|
// 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
|
// `transmit set inhibit=` is the whole mechanism. We used to also send
|
||||||
// blocked. The reason is best-effort (a separate interlock object); the inhibit
|
// `interlock set reason=OpsLog` to label WHY TX was blocked, but `reason` is a
|
||||||
// is what actually blocks TX, so a rig that ignores the reason still stays safe.
|
// read-only status field on the interlock object — writing it is rejected
|
||||||
|
// (cmd error 5000002D on SmartSDR v1.4/v3) and does nothing, so it's dropped.
|
||||||
func (f *Flex) SetTXInhibit(on bool) error {
|
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 })
|
return f.txSet("transmit set inhibit="+boolFlex(on), "inhibit", func(t *flexTX) { t.inhibit = on })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+49
-17
@@ -224,11 +224,14 @@ func (b *IcomSerial) Connect() error {
|
|||||||
idAddr = f.Data[1]
|
idAddr = f.Data[1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub
|
// Whether the rig's scope protocol carries a leading main/sub SELECTOR byte on
|
||||||
// selector byte; single-scope rigs (IC-7300…) do not. Decide from the
|
// every 0x27 frame and command. The IC-7610/9700 (dual scope) do — and the logs
|
||||||
// IDENTIFIED model, NOT the configured address: an IC-7300 run at 0x98 must
|
// prove the IC-7300 does too (frames arrive as `27 00 00 <seq> <total> …` and a
|
||||||
// still parse single-scope frames (this was the "scope blank on the 7300" bug).
|
// mode read answers `27 14 00 <mode>`), even though it has a single scope. The
|
||||||
b.dualScope = idAddr == 0x98 || idAddr == 0xA2
|
// waveform parser auto-detects this per-frame regardless, so a 7300 at a
|
||||||
|
// non-default address still RENDERS; this flag only drives the SET/read commands
|
||||||
|
// (mode, span, edges), which need the 0x00 selector to be accepted on the 7300.
|
||||||
|
b.dualScope = idAddr == 0x98 || idAddr == 0xA2 || idAddr == 0x94
|
||||||
// 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
|
||||||
@@ -603,12 +606,20 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
|
// Frame layout after the 0x00 sub-command byte is one of:
|
||||||
|
// [selector][seq][total][data…] (IC-7610/9700 AND, as the logs prove,
|
||||||
|
// the IC-7300 — selector 0x00 = main)
|
||||||
|
// [seq][total][data…] (a rig with no selector byte)
|
||||||
|
// The sequence starts at 1, so a 0x00 at Data[1] can only be the main-scope
|
||||||
|
// selector — never a valid sequence. Keying this off the rig ADDRESS was the
|
||||||
|
// long-standing "blank scope on the IC-7300" bug: the 7300 DOES send the
|
||||||
|
// selector, so idx stayed 1, seq was read as 0x00, and every frame was
|
||||||
|
// dropped. Detect it from the byte itself instead — address-independent.
|
||||||
idx := 1
|
idx := 1
|
||||||
if b.dualScope {
|
if len(f.Data) >= 2 && f.Data[1] == 0x00 {
|
||||||
if len(f.Data) < 2 || f.Data[1] != 0x00 {
|
idx = 2 // leading main-scope selector byte present
|
||||||
continue // only the MAIN scope
|
} else if b.dualScope {
|
||||||
}
|
continue // dual-scope rig SUB frame (selector != 0x00) — main only
|
||||||
idx = 2
|
|
||||||
}
|
}
|
||||||
if len(f.Data) < idx+2 {
|
if len(f.Data) < idx+2 {
|
||||||
continue
|
continue
|
||||||
@@ -661,9 +672,23 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
|||||||
if seq == 1 { // header frame — begins a new sweep, no waveform data
|
if seq == 1 { // header frame — begins a new sweep, no waveform data
|
||||||
regions = make(map[byte][]byte)
|
regions = make(map[byte][]byte)
|
||||||
total = tot
|
total = tot
|
||||||
if len(region) >= 11 { // [info][low 5][high 5]
|
if len(region) >= 11 { // [info][freq1 5-BCD][freq2 5-BCD]
|
||||||
low := civ.BCDToFreq(region[1:6])
|
// Same center/fixed disambiguation as the single-frame (net) path:
|
||||||
high := civ.BCDToFreq(region[6:11])
|
// FIXED sends [low-edge][high-edge] (both absolute), CENTER sends
|
||||||
|
// [center][span]. Tell them apart by magnitude — a second value
|
||||||
|
// BIGGER than the first is a real high edge; a smaller one is a
|
||||||
|
// span. Without this, an IC-7300 in center (VFO-following) mode set
|
||||||
|
// high = span (e.g. 100 kHz), so high < low and the panadapter had
|
||||||
|
// no valid range to map the trace onto → blank scope. FIXED mode
|
||||||
|
// parsed fine, which is why only center mode was broken.
|
||||||
|
v1 := civ.BCDToFreq(region[1:6])
|
||||||
|
v2 := civ.BCDToFreq(region[6:11])
|
||||||
|
var low, high int64
|
||||||
|
if v2 > v1 {
|
||||||
|
low, high = v1, v2 // absolute low/high edges (fixed)
|
||||||
|
} else {
|
||||||
|
low, high = v1-v2/2, v1+v2/2 // centre + span (centre-on-VFO)
|
||||||
|
}
|
||||||
b.scopeMu.Lock()
|
b.scopeMu.Lock()
|
||||||
b.scopeLow, b.scopeHigh = low, high
|
b.scopeLow, b.scopeHigh = low, high
|
||||||
b.scopeMu.Unlock()
|
b.scopeMu.Unlock()
|
||||||
@@ -711,12 +736,19 @@ func (b *IcomSerial) SetScope(on bool) error {
|
|||||||
// but nothing shows, compare its `icom scope raw` frames against this.
|
// 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)",
|
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])
|
b.model, b.rigAddr, b.dualScope, map[bool]string{true: "27 00 [MS] [seq] [total] …", false: "27 00 [seq] [total] …"}[b.dualScope])
|
||||||
|
// Turn the scope DISPLAY on — needed so the rig actually streams (esp. when
|
||||||
|
// remote and we can't touch the front panel). ONLY on enable: we must never
|
||||||
|
// switch the display OFF, because that is the operator's own scope on the
|
||||||
|
// radio, and closing OpsLog (SetScope(false)) blanking a local IC-7300's
|
||||||
|
// screen is exactly the regression this avoids. Some firmwares don't ack a
|
||||||
|
// 0x27 set; a timeout isn't fatal, so log and continue.
|
||||||
|
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, 0x01); err != nil {
|
||||||
|
applog.Printf("icom scope: display on ack: %v", err)
|
||||||
}
|
}
|
||||||
// Some firmwares don't ack 0x27 sets; a timeout here isn't fatal, so log and
|
|
||||||
// continue rather than abort the second command.
|
|
||||||
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil {
|
|
||||||
applog.Printf("icom scope: display on=%v ack: %v", on, err)
|
|
||||||
}
|
}
|
||||||
|
// Waveform data OUTPUT over CI-V: enabled with the scope, and — crucially —
|
||||||
|
// the ONLY thing we switch off on disable, so the radio's own scope display is
|
||||||
|
// left exactly as the operator had it.
|
||||||
if err := b.exec(civ.CmdScope, civ.SubScopeOn, boolByte(on)); err != nil {
|
if err := b.exec(civ.CmdScope, civ.SubScopeOn, boolByte(on)); err != nil {
|
||||||
applog.Printf("icom scope: output on=%v ack: %v", on, err)
|
applog.Printf("icom scope: output on=%v ack: %v", on, err)
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-4
@@ -267,12 +267,23 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
|||||||
// method (RX=TX=freq, simplex). It works on rigs — notably Icom (IC-9100) —
|
// method (RX=TX=freq, simplex). It works on rigs — notably Icom (IC-9100) —
|
||||||
// where direct FreqA/FreqB writes are accepted but never move the radio.
|
// where direct FreqA/FreqB writes are accepted but never move the radio.
|
||||||
// Clearing split is the right thing when tuning to a spot anyway.
|
// Clearing split is the right thing when tuning to a spot anyway.
|
||||||
|
simplexOK := false
|
||||||
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
|
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
|
||||||
|
simplexOK = true
|
||||||
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32)
|
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32)
|
||||||
} else {
|
} else {
|
||||||
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v) — using property writes", err)
|
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v)", err)
|
||||||
// Fallback: write the active VFO's property AND the generic Freq
|
}
|
||||||
// (always — some .ini honour only one, and split here is often misread).
|
|
||||||
|
// On Yaesu / Kenwood (and anything non-Icom), SetSimplexMode frequently returns
|
||||||
|
// OK but is a SILENT NO-OP — the rig never moves. Confirmed on an FT-891 over
|
||||||
|
// OmniRig: every spot click logged "SetSimplexMode OK" yet FreqA stayed put,
|
||||||
|
// while SetMode on the same .ini worked fine (so the CAT link is healthy).
|
||||||
|
// Writing the VFO frequency PROPERTY directly DOES move them, so also do that
|
||||||
|
// here. It is skipped on Icom, where the direct write is the unreliable one and
|
||||||
|
// could nudge the wrong Main/Sub VFO — there SetSimplexMode is authoritative.
|
||||||
|
isIcom := strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rigType)), "IC")
|
||||||
|
if !isIcom || !simplexOK {
|
||||||
prop := "FreqA"
|
prop := "FreqA"
|
||||||
switch vfo {
|
switch vfo {
|
||||||
case "B", "BB", "BA":
|
case "B", "BB", "BA":
|
||||||
@@ -287,7 +298,7 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
|||||||
okAny = true
|
okAny = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !okAny {
|
if !simplexOK && !okAny {
|
||||||
return fmt.Errorf("OmniRig: no writable frequency property for this rig")
|
return fmt.Errorf("OmniRig: no writable frequency property for this rig")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package clublog
|
||||||
|
|
||||||
|
// mostwanted.go — the ClubLog "Most Wanted" DXCC ranking. ClubLog publishes, per
|
||||||
|
// callsign, how wanted each DXCC entity is (rank 1 = the single most wanted, e.g.
|
||||||
|
// P5 North Korea). We fetch it, invert it to a DXCC-number → rank map, cache it on
|
||||||
|
// disk (personalised to the operator's callsign) and refresh daily. The rank is
|
||||||
|
// surfaced next to the entity in the entry-strip band/slot matrix.
|
||||||
|
//
|
||||||
|
// Endpoint (same one DXHunter uses): mostwanted.php?api=1 returns JSON
|
||||||
|
// { "1": "344", "2": "123", ... } rank → ADIF (DXCC) number
|
||||||
|
// Passing &callsign=<CALL> personalises the ranking to that operator's log.
|
||||||
|
// api=1 is just the "return JSON" flag — no API key is needed here.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const mwURL = "https://clublog.org/mostwanted.php?api=1"
|
||||||
|
const mwFile = "clublog_mostwanted.json"
|
||||||
|
|
||||||
|
// mwUserAgent — ClubLog's nginx 403s the default Go user-agent, so set a real one.
|
||||||
|
const mwUserAgent = "OpsLog (amateur radio logger)"
|
||||||
|
|
||||||
|
// mwCache is the on-disk shape (ranks keyed by DXCC number as a string for JSON).
|
||||||
|
type mwCache struct {
|
||||||
|
Callsign string `json:"callsign"`
|
||||||
|
FetchedAt time.Time `json:"fetched_at"`
|
||||||
|
Ranks map[string]int `json:"ranks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MostWanted owns the on-disk cache and the parsed DXCC-number → rank map.
|
||||||
|
type MostWanted struct {
|
||||||
|
dir string
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
ranks map[int]int // dxcc number → rank (1 = most wanted)
|
||||||
|
call string // callsign the current ranks were fetched for
|
||||||
|
fetched time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMostWanted(dataDir string) *MostWanted {
|
||||||
|
return &MostWanted{dir: dataDir, ranks: map[int]int{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MostWanted) path() string { return filepath.Join(m.dir, mwFile) }
|
||||||
|
|
||||||
|
// Rank returns the most-wanted rank for a DXCC number, or 0 if unknown/unloaded.
|
||||||
|
func (m *MostWanted) Rank(dxcc int) int {
|
||||||
|
if dxcc <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.ranks[dxcc]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ranks returns a copy of the whole DXCC-number → rank map.
|
||||||
|
func (m *MostWanted) Ranks() map[int]int {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
out := make(map[int]int, len(m.ranks))
|
||||||
|
for k, v := range m.ranks {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info reports the callsign the list was fetched for, its date, and entity count.
|
||||||
|
func (m *MostWanted) Info() (call, date string, count int) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
d := ""
|
||||||
|
if !m.fetched.IsZero() {
|
||||||
|
d = m.fetched.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
return m.call, d, len(m.ranks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MostWanted) Loaded() bool {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return len(m.ranks) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadFromDisk reads the cached JSON into memory. Does NOT download.
|
||||||
|
func (m *MostWanted) LoadFromDisk() error {
|
||||||
|
b, err := os.ReadFile(m.path())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var c mwCache
|
||||||
|
if err := json.Unmarshal(b, &c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ranks := make(map[int]int, len(c.Ranks))
|
||||||
|
for k, v := range c.Ranks {
|
||||||
|
if n, err := strconv.Atoi(k); err == nil && n > 0 && v > 0 {
|
||||||
|
ranks[n] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.ranks = ranks
|
||||||
|
m.call = c.Callsign
|
||||||
|
m.fetched = c.FetchedAt
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsRefresh reports whether the list is empty, older than maxAge, or was
|
||||||
|
// fetched for a DIFFERENT callsign (the ranking is personalised, so a profile
|
||||||
|
// switch invalidates it).
|
||||||
|
func (m *MostWanted) NeedsRefresh(callsign string, maxAge time.Duration) bool {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
if len(m.ranks) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(callsign), m.call) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return time.Since(m.fetched) > maxAge
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download fetches the most-wanted list for callsign, writes it atomically, and
|
||||||
|
// swaps it into memory.
|
||||||
|
func (m *MostWanted) Download(ctx context.Context, callsign string) error {
|
||||||
|
if err := os.MkdirAll(m.dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(callsign))
|
||||||
|
url := mwURL
|
||||||
|
if call != "" {
|
||||||
|
url += "&callsign=" + call
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", mwUserAgent)
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("download: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("clublog mostwanted HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// { "1": "344", "2": "123", ... } — rank → ADIF (DXCC) number.
|
||||||
|
var raw map[string]string
|
||||||
|
if err := json.Unmarshal(body, &raw); err != nil {
|
||||||
|
return fmt.Errorf("clublog mostwanted parse: %w", err)
|
||||||
|
}
|
||||||
|
ranks := make(map[int]int, len(raw))
|
||||||
|
strRanks := make(map[string]int, len(raw))
|
||||||
|
for rankStr, adif := range raw {
|
||||||
|
rank, e1 := strconv.Atoi(strings.TrimSpace(rankStr))
|
||||||
|
dxcc, e2 := strconv.Atoi(strings.TrimSpace(adif))
|
||||||
|
if e1 == nil && e2 == nil && rank > 0 && dxcc > 0 {
|
||||||
|
ranks[dxcc] = rank
|
||||||
|
strRanks[adif] = rank
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ranks) == 0 {
|
||||||
|
return fmt.Errorf("clublog mostwanted: empty/invalid response")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if b, err := json.Marshal(mwCache{Callsign: call, FetchedAt: now, Ranks: strRanks}); err == nil {
|
||||||
|
tmp := m.path() + ".tmp"
|
||||||
|
if os.WriteFile(tmp, b, 0o644) == nil {
|
||||||
|
_ = os.Rename(tmp, m.path())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.ranks = ranks
|
||||||
|
m.call = call
|
||||||
|
m.fetched = now
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
|
||||||
|
// mono PCM samples into decoded text.
|
||||||
|
//
|
||||||
|
// This is the second generation of the decoder. The first one worked on clean
|
||||||
|
// machine keying but fell apart on real signals; every stage below exists to
|
||||||
|
// fix a specific failure of that version (and of naïve Goertzel decoders in
|
||||||
|
// general):
|
||||||
|
//
|
||||||
|
// audio ─ Hamming-windowed Goertzel bank ─ pitch lock ─ dB envelope with
|
||||||
|
// separate noise-floor / peak trackers ─ hysteresis slicer + SNR squelch ─
|
||||||
|
// DEBOUNCED mark/space stream ─ two-cluster dit/dah length tracking ─
|
||||||
|
// element / character / word segmentation ─ Morse table ─ text
|
||||||
|
//
|
||||||
|
// The fixes that matter, in order of impact:
|
||||||
|
//
|
||||||
|
// 1. Debounced transitions BOTH ways. The old decoder rejected too-short
|
||||||
|
// marks but not too-short SPACES, so a one-hop fade inside a dah (QSB,
|
||||||
|
// static crash) split it into two dits — the single biggest source of
|
||||||
|
// garbage on real signals. Here a state flip must persist for a glitch
|
||||||
|
// time (~0.3 dit) before it is committed; shorter flips are folded back
|
||||||
|
// into the surrounding element.
|
||||||
|
//
|
||||||
|
// 2. Two-cluster timing. Dit and dah lengths are tracked as two separate
|
||||||
|
// moving centres with the decision boundary at their geometric mean,
|
||||||
|
// instead of one EMA "dot length" that both classifies marks and is
|
||||||
|
// updated by that same classification (a feedback loop that spiralled to
|
||||||
|
// "all dits at 60 WPM" the moment it started mis-classifying). The
|
||||||
|
// inter-element gaps also feed the dit centre — spaces are timing
|
||||||
|
// evidence too, and hand keying is often more regular in its gaps than
|
||||||
|
// in its dits.
|
||||||
|
//
|
||||||
|
// 3. dB-domain envelope. Peak and noise floor are tracked in dB with
|
||||||
|
// asymmetric attack/decay, the slicer runs at 55%/38% of the span with
|
||||||
|
// hysteresis, and a minimum-span squelch (6 dB) keeps pure noise from
|
||||||
|
// keying at all. In the old linear-magnitude scheme weak signals lived
|
||||||
|
// in the bottom few percent of the scale and QSB swallowed them.
|
||||||
|
//
|
||||||
|
// 4. Windowed Goertzel. A Hamming window tames spectral leakage so a strong
|
||||||
|
// tone doesn't bleed across the whole bank and corrupt both the noise
|
||||||
|
// estimate and the lock choice.
|
||||||
|
//
|
||||||
|
// Kept from the first version because they were right: the pitch LOCK (decode
|
||||||
|
// one tone, ignore QRM at other pitches), pitch targeting (follow the radio's
|
||||||
|
// known CW pitch instead of searching — SetTarget), tiered acquisition
|
||||||
|
// (strong signals lock on the first hop so their opening dit isn't eaten),
|
||||||
|
// the floor frozen during key-down (a rising floor mid-dah fragments it), and
|
||||||
|
// the end-of-over flush when the lock releases.
|
||||||
|
//
|
||||||
|
// Deliberately dependency-free and fed by plain []int16 so the whole pipeline
|
||||||
|
// is unit-tested with synthetic signals (see cwdecode_test.go: clean keying at
|
||||||
|
// several speeds, added noise, QSB fading, QRM on a nearby pitch, and a
|
||||||
|
// noise-only squelch test).
|
||||||
|
//
|
||||||
|
// Honest expectations: on clean or moderately noisy signals this decodes
|
||||||
|
// solidly; very weak signals in heavy QRM remain hard for any envelope
|
||||||
|
// decoder — the tools that shine there (CW Skimmer, SDC) use probabilistic
|
||||||
|
// sequence estimation on top. This stage is designed so such a layer could be
|
||||||
|
// added later without touching the DSP.
|
||||||
|
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.
|
||||||
|
// Process must be called from a single goroutine; SetTarget is safe to call
|
||||||
|
// concurrently.
|
||||||
|
type Decoder struct {
|
||||||
|
fs int
|
||||||
|
hop int // samples between analyses (~6 ms)
|
||||||
|
win int // Goertzel window length (~20 ms)
|
||||||
|
hopMs float64 // hop duration in ms
|
||||||
|
biasMs float64 // envelope widening caused by the analysis window (see below)
|
||||||
|
|
||||||
|
window []float64 // Hamming window, len win
|
||||||
|
ring []float64 // circular raw-sample buffer, len win
|
||||||
|
rpos int // next write position in ring
|
||||||
|
filled int // samples written so far (until >= win)
|
||||||
|
acc int // samples since last analysis
|
||||||
|
|
||||||
|
ws []float64 // scratch: windowed samples for this hop
|
||||||
|
|
||||||
|
// Search bank (only run while unlocked / untargeted).
|
||||||
|
freqs []float64
|
||||||
|
coeffs []float64
|
||||||
|
mags []float64 // dB per bin
|
||||||
|
nbuf []float64 // scratch for the median
|
||||||
|
|
||||||
|
// Fixed-pitch target (Hz). 0 = auto-search; >0 = decode exactly this pitch
|
||||||
|
// and ignore everything else (e.g. follow the radio's CW pitch). Set live
|
||||||
|
// from another goroutine, so it's atomic.
|
||||||
|
targetHz atomic.Int32
|
||||||
|
targetFor float64 // freq the cached target coeff was computed for
|
||||||
|
targetCoeff float64
|
||||||
|
|
||||||
|
// Pitch lock.
|
||||||
|
lockIdx int // bin index while auto-locked; -1 = unlocked
|
||||||
|
candIdx int
|
||||||
|
candHops int
|
||||||
|
quietHops int // consecutive key-up hops while locked (drives release)
|
||||||
|
bankTick int // hops since the bank last ran while locked
|
||||||
|
betterHops int // evidence that a different bin is the real signal
|
||||||
|
|
||||||
|
// Envelope (dB domain) on the locked/target tone.
|
||||||
|
floorDB, peakDB float64
|
||||||
|
bankNoiseDB float64 // broadband reference: EMA of the bank median
|
||||||
|
haveBankNoise bool
|
||||||
|
envSeeded bool
|
||||||
|
rawKey bool // slicer output this hop
|
||||||
|
|
||||||
|
// Debounced mark/space state machine.
|
||||||
|
key bool // committed state (true = mark)
|
||||||
|
stableHops int // hops in the committed state
|
||||||
|
pendHops int // consecutive hops the raw state has disagreed
|
||||||
|
|
||||||
|
// Two-cluster element timing (ms).
|
||||||
|
muDit, muDah float64
|
||||||
|
marksSeen int
|
||||||
|
|
||||||
|
// Character assembly. Element DURATIONS are stored and only classified
|
||||||
|
// into dits/dahs when the character is flushed: by then the character's
|
||||||
|
// own marks are all known, and a bimodal batch carries its own dit/dah
|
||||||
|
// boundary — so even the very first character of an over decodes
|
||||||
|
// correctly at any speed, before the global clusters have converged.
|
||||||
|
elemMs []float64
|
||||||
|
charEmitted bool
|
||||||
|
wordEmitted bool
|
||||||
|
textSince bool // something was decoded since lock (guards leading spaces)
|
||||||
|
|
||||||
|
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',
|
||||||
|
".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=',
|
||||||
|
".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tunables (hops are ~6 ms).
|
||||||
|
const (
|
||||||
|
minDitMs = 20.0 // 60 WPM ceiling
|
||||||
|
maxDitMs = 240.0 // 5 WPM floor
|
||||||
|
seedDit = 60.0 // 20 WPM seed before any marks are seen
|
||||||
|
|
||||||
|
acqStrongDB = 12.0 // lock on the FIRST hop above this SNR (don't eat the opening dit)
|
||||||
|
acqWeakDB = 8.5 // lock after sustained hops above this SNR
|
||||||
|
// Successive analysis windows overlap ~70%, so consecutive hops are highly
|
||||||
|
// correlated — a noise spike easily "persists" 2–3 hops. Requiring ~2 full
|
||||||
|
// window lengths of persistence makes a false noise lock genuinely rare.
|
||||||
|
acqWeakHops = 6
|
||||||
|
|
||||||
|
squelchDB = 6.0 // minimum peak-floor span to key at all
|
||||||
|
// The span alone can't reject pure noise: a noise bin's dB level swings
|
||||||
|
// ±5–6 dB, which the peak/floor trackers happily turn into a keyable
|
||||||
|
// span. The second squelch is ABSOLUTE: the envelope peak must stand well
|
||||||
|
// above the broadband noise reference (the bank median) — a keyed tone
|
||||||
|
// does, noise never sustainably does.
|
||||||
|
snrSquelchDB = 9.5
|
||||||
|
|
||||||
|
// The slicer's span is CAPPED: with a very quiet background (high SNR, or
|
||||||
|
// digitally silent test signals) the raw floor sits so far below the peak
|
||||||
|
// that the off-threshold lands in the analysis window's skirts — every
|
||||||
|
// mark then stretches by almost a full window and every gap shrinks,
|
||||||
|
// until character gaps fall below the segmentation threshold and letters
|
||||||
|
// merge. Capping the usable span keeps the slicer crossing near the
|
||||||
|
// signal edges regardless of how quiet the background is.
|
||||||
|
spanCapDB = 30.0
|
||||||
|
|
||||||
|
onFrac = 0.55 // slicer thresholds as a fraction of the (capped) span…
|
||||||
|
offFrac = 0.38 // …with hysteresis
|
||||||
|
|
||||||
|
charGapDits = 2.2 // gap > this ⇒ character boundary (geom. mean of 1 & 3 ≈ 1.7, plus margin for sloppy fists)
|
||||||
|
wordGapDits = 4.6 // gap > this ⇒ word boundary (geom. mean of 3 & 7)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 * 5 / 1000, // 5 ms — resolves dits up to ~50 WPM
|
||||||
|
win: sampleRate * 16 / 1000, // 16 ms window (≈80 Hz bandwidth with Hamming; a 20 ms window left 40 WPM inter-element gaps with almost no envelope dip)
|
||||||
|
lockIdx: -1,
|
||||||
|
candIdx: -1,
|
||||||
|
muDit: seedDit,
|
||||||
|
muDah: 3 * seedDit,
|
||||||
|
onChar: onChar,
|
||||||
|
onStatus: onStatus,
|
||||||
|
}
|
||||||
|
if d.hop < 1 {
|
||||||
|
d.hop = 1
|
||||||
|
}
|
||||||
|
if d.win < 4*d.hop {
|
||||||
|
d.win = 4 * d.hop
|
||||||
|
}
|
||||||
|
d.hopMs = float64(d.hop) / float64(d.fs) * 1000
|
||||||
|
// Even with the span cap, the analysis window widens every mark and
|
||||||
|
// narrows every gap by roughly half a window on each edge (the tone leaks
|
||||||
|
// into windows that straddle an edge). Durations are de-biased by this
|
||||||
|
// constant so the timing clusters and gap thresholds see true lengths.
|
||||||
|
d.biasMs = 0.6 * float64(d.win) / float64(d.fs) * 1000
|
||||||
|
d.statusEvery = int(math.Max(1, 100/d.hopMs)) // ~10 Hz
|
||||||
|
d.ring = make([]float64, d.win)
|
||||||
|
d.ws = make([]float64, d.win)
|
||||||
|
// Hamming window: −43 dB sidelobes keep a strong tone from bleeding across
|
||||||
|
// the bank (rectangular Goertzel leaks at −13 dB, enough to fool the lock).
|
||||||
|
d.window = make([]float64, d.win)
|
||||||
|
for i := range d.window {
|
||||||
|
d.window[i] = 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(d.win-1))
|
||||||
|
}
|
||||||
|
// Candidate CW tones: 400–1000 Hz every 30 Hz. Deliberately NOT lower:
|
||||||
|
// low-frequency hum/rumble rises toward DC and would win the argmax.
|
||||||
|
for f := 400.0; f <= 1000.0; f += 30 {
|
||||||
|
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 (an exact-frequency detector, not the
|
||||||
|
// nearest search bin), or returns to auto-search when hz <= 0. Safe to call
|
||||||
|
// concurrently.
|
||||||
|
func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) }
|
||||||
|
|
||||||
|
// Reset clears decode state (e.g. when the user re-arms the decoder).
|
||||||
|
func (d *Decoder) Reset() {
|
||||||
|
d.rpos, d.filled, d.acc = 0, 0, 0
|
||||||
|
d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0
|
||||||
|
d.envSeeded, d.rawKey, d.key = false, false, false
|
||||||
|
d.haveBankNoise = false
|
||||||
|
d.stableHops, d.pendHops = 0, 0
|
||||||
|
d.bankTick, d.betterHops = 0, 0
|
||||||
|
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
|
||||||
|
d.elemMs = d.elemMs[:0]
|
||||||
|
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process feeds a block of mono samples through the decoder.
|
||||||
|
func (d *Decoder) Process(samples []int16) {
|
||||||
|
for _, s := range samples {
|
||||||
|
d.ring[d.rpos] = float64(s)
|
||||||
|
d.rpos++
|
||||||
|
if d.rpos == d.win {
|
||||||
|
d.rpos = 0
|
||||||
|
}
|
||||||
|
if d.filled < d.win {
|
||||||
|
d.filled++
|
||||||
|
}
|
||||||
|
d.acc++
|
||||||
|
if d.acc >= d.hop && d.filled >= d.win {
|
||||||
|
d.acc = 0
|
||||||
|
d.hopStep()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// goertzelDB returns the tone power (dB, arbitrary reference) of the windowed
|
||||||
|
// scratch buffer at the detector coefficient c.
|
||||||
|
func (d *Decoder) goertzelDB(c float64) float64 {
|
||||||
|
var s1, s2 float64
|
||||||
|
for _, x := range d.ws {
|
||||||
|
s0 := x + c*s1 - s2
|
||||||
|
s2 = s1
|
||||||
|
s1 = s0
|
||||||
|
}
|
||||||
|
p := s1*s1 + s2*s2 - c*s1*s2
|
||||||
|
if p < 1e-12 {
|
||||||
|
p = 1e-12
|
||||||
|
}
|
||||||
|
return 10 * math.Log10(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hopStep runs one analysis hop: tone detection, envelope, slicer, timing.
|
||||||
|
func (d *Decoder) hopStep() {
|
||||||
|
// Materialize the window (oldest→newest; order is irrelevant for power)
|
||||||
|
// and the RMS level for the UI meter.
|
||||||
|
var sumSq float64
|
||||||
|
for i := 0; i < d.win; i++ {
|
||||||
|
x := d.ring[(d.rpos+i)%d.win]
|
||||||
|
d.ws[i] = x * d.window[i]
|
||||||
|
sumSq += x * x
|
||||||
|
}
|
||||||
|
d.lastRMS = math.Min(1, math.Sqrt(sumSq/float64(d.win))/32768*4)
|
||||||
|
|
||||||
|
toneDB, haveTone := 0.0, false
|
||||||
|
|
||||||
|
if th := float64(d.targetHz.Load()); th > 0 {
|
||||||
|
// Fixed pitch: one exact-frequency detector, like a skimmer channel.
|
||||||
|
if th != d.targetFor {
|
||||||
|
d.targetFor = th
|
||||||
|
d.targetCoeff = 2 * math.Cos(2*math.Pi*th/float64(d.fs))
|
||||||
|
d.envSeeded = false // re-seed the envelope for the new channel
|
||||||
|
}
|
||||||
|
toneDB = d.goertzelDB(d.targetCoeff)
|
||||||
|
d.lastPitch = th
|
||||||
|
d.lockIdx = -1 // targeting supersedes the auto lock
|
||||||
|
haveTone = true
|
||||||
|
// Refresh the broadband noise reference for the absolute squelch.
|
||||||
|
d.bankTick++
|
||||||
|
if d.bankTick >= 8 {
|
||||||
|
d.bankTick = 0
|
||||||
|
for i, c := range d.coeffs {
|
||||||
|
d.mags[i] = d.goertzelDB(c)
|
||||||
|
}
|
||||||
|
copy(d.nbuf, d.mags)
|
||||||
|
sort.Float64s(d.nbuf)
|
||||||
|
d.updateBankNoise(d.nbuf[len(d.nbuf)/2])
|
||||||
|
}
|
||||||
|
} else if d.lockIdx >= 0 {
|
||||||
|
// Auto-locked: only the locked bin is needed per hop.
|
||||||
|
toneDB = d.goertzelDB(d.coeffs[d.lockIdx])
|
||||||
|
d.lastPitch = d.freqs[d.lockIdx]
|
||||||
|
haveTone = true
|
||||||
|
// Supervised re-lock: periodically sweep the whole bank anyway. If a
|
||||||
|
// clearly stronger tone lives on a DIFFERENT pitch and keeps doing so,
|
||||||
|
// the current lock is wrong (locked onto noise, or the operator moved)
|
||||||
|
// — jump to the real signal instead of decoding garbage until the
|
||||||
|
// quiet-release finally fires.
|
||||||
|
d.bankTick++
|
||||||
|
if d.bankTick >= 8 {
|
||||||
|
d.bankTick = 0
|
||||||
|
bestIdx, bestDB := -1, math.Inf(-1)
|
||||||
|
for i, c := range d.coeffs {
|
||||||
|
m := d.goertzelDB(c)
|
||||||
|
d.mags[i] = m
|
||||||
|
if i >= d.lockIdx-1 && i <= d.lockIdx+1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m > bestDB {
|
||||||
|
bestDB, bestIdx = m, i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
copy(d.nbuf, d.mags)
|
||||||
|
sort.Float64s(d.nbuf)
|
||||||
|
d.updateBankNoise(d.nbuf[len(d.nbuf)/2])
|
||||||
|
if bestIdx >= 0 && bestDB > toneDB+6 {
|
||||||
|
d.betterHops += 2
|
||||||
|
} else if d.betterHops > 0 {
|
||||||
|
d.betterHops--
|
||||||
|
}
|
||||||
|
if d.betterHops >= 24 && bestIdx >= 0 { // ~12 confirmations over ~1 s
|
||||||
|
d.flushPending()
|
||||||
|
copy(d.nbuf, d.mags)
|
||||||
|
sort.Float64s(d.nbuf)
|
||||||
|
d.lockIdx = bestIdx
|
||||||
|
// Seed the envelope honestly: floor from the bank median, NOT
|
||||||
|
// peak−cap — fabricating a full span would let a noise re-lock
|
||||||
|
// key freely until the trackers converged.
|
||||||
|
d.floorDB, d.peakDB = d.nbuf[len(d.nbuf)/2], bestDB
|
||||||
|
d.quietHops, d.bankTick, d.betterHops = 0, 0, 0
|
||||||
|
d.marksSeen = 0
|
||||||
|
d.elemMs = d.elemMs[:0]
|
||||||
|
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
|
||||||
|
d.key, d.stableHops, d.pendHops = false, 0, 0
|
||||||
|
toneDB = d.goertzelDB(d.coeffs[d.lockIdx])
|
||||||
|
d.lastPitch = d.freqs[d.lockIdx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unlocked: run the whole bank, estimate noise, hunt for a tone.
|
||||||
|
maxIdx, maxDB := 0, math.Inf(-1)
|
||||||
|
for i, c := range d.coeffs {
|
||||||
|
m := d.goertzelDB(c)
|
||||||
|
d.mags[i] = m
|
||||||
|
if m > maxDB {
|
||||||
|
maxDB, maxIdx = m, i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
copy(d.nbuf, d.mags)
|
||||||
|
sort.Float64s(d.nbuf)
|
||||||
|
noise := d.nbuf[len(d.nbuf)/2] // median: robust to a few strong tones
|
||||||
|
d.updateBankNoise(noise)
|
||||||
|
snr := maxDB - noise
|
||||||
|
|
||||||
|
near := d.candIdx >= 0 && maxIdx >= d.candIdx-1 && maxIdx <= d.candIdx+1
|
||||||
|
if near {
|
||||||
|
d.candHops++
|
||||||
|
} else {
|
||||||
|
d.candIdx, d.candHops = maxIdx, 1
|
||||||
|
}
|
||||||
|
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so
|
||||||
|
// its opening dit isn't eaten), a marginal one must persist a few hops
|
||||||
|
// (so we don't lock onto a noise spike).
|
||||||
|
if snr > acqStrongDB || (d.candHops >= acqWeakHops && snr > acqWeakDB) {
|
||||||
|
d.lockIdx = maxIdx
|
||||||
|
d.floorDB, d.peakDB = noise, maxDB
|
||||||
|
d.envSeeded = true
|
||||||
|
d.quietHops = 0
|
||||||
|
d.bankTick, d.betterHops = 0, 0
|
||||||
|
d.marksSeen = 0 // relearn speed quickly for this signal (keep muDit as seed)
|
||||||
|
d.elemMs = d.elemMs[:0]
|
||||||
|
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
|
||||||
|
d.key, d.stableHops, d.pendHops = false, 0, 0
|
||||||
|
toneDB = maxDB
|
||||||
|
d.lastPitch = d.freqs[maxIdx]
|
||||||
|
haveTone = true
|
||||||
|
} else {
|
||||||
|
d.lastPitch = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !haveTone {
|
||||||
|
d.rawKey = false
|
||||||
|
d.emitStatus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !d.envSeeded {
|
||||||
|
// First hop on a targeted channel: seed from the current reading.
|
||||||
|
d.floorDB, d.peakDB = toneDB, toneDB+squelchDB
|
||||||
|
d.envSeeded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Envelope: separate floor / peak trackers, dB domain. ----
|
||||||
|
// Floor drops fast, but only RISES while keyed up: a floor creeping up
|
||||||
|
// under a long dah shrinks the span until the dah fragments into dits.
|
||||||
|
if toneDB < d.floorDB {
|
||||||
|
d.floorDB += (toneDB - d.floorDB) * 0.3
|
||||||
|
} else if !d.rawKey {
|
||||||
|
d.floorDB += (toneDB - d.floorDB) * 0.015
|
||||||
|
}
|
||||||
|
// Peak attacks fast and decays slowly toward current conditions (~1.5 s),
|
||||||
|
// so QSB is followed without collapsing across ordinary word gaps.
|
||||||
|
if toneDB > d.peakDB {
|
||||||
|
d.peakDB += (toneDB - d.peakDB) * 0.4
|
||||||
|
} else {
|
||||||
|
d.peakDB += (toneDB - d.peakDB) * 0.004
|
||||||
|
}
|
||||||
|
if d.peakDB < d.floorDB {
|
||||||
|
d.peakDB = d.floorDB
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Slicer with hysteresis + SNR squelch. ----
|
||||||
|
if d.peakDB-d.floorDB < squelchDB ||
|
||||||
|
(d.haveBankNoise && d.peakDB < d.bankNoiseDB+snrSquelchDB) {
|
||||||
|
d.rawKey = false
|
||||||
|
} else {
|
||||||
|
// Cap the usable span so the slicer crossings stay near the keying
|
||||||
|
// edges even over a dead-quiet background (see spanCapDB).
|
||||||
|
effFloor := math.Max(d.floorDB, d.peakDB-spanCapDB)
|
||||||
|
span := d.peakDB - effFloor
|
||||||
|
if d.rawKey {
|
||||||
|
d.rawKey = toneDB > effFloor+offFrac*span
|
||||||
|
} else {
|
||||||
|
d.rawKey = toneDB > effFloor+onFrac*span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Auto-lock release after a long quiet spell. ----
|
||||||
|
if d.lockIdx >= 0 {
|
||||||
|
if d.rawKey {
|
||||||
|
d.quietHops = 0
|
||||||
|
} else {
|
||||||
|
d.quietHops++
|
||||||
|
// Long enough to survive slow-speed word gaps (7 dits), short
|
||||||
|
// enough to retune to a new signal within a few seconds.
|
||||||
|
release := int(math.Max(2000, 12*d.muDit) / d.hopMs)
|
||||||
|
if d.quietHops > release {
|
||||||
|
d.flushPending()
|
||||||
|
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
|
||||||
|
d.rawKey = false
|
||||||
|
d.lastPitch = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d.timing()
|
||||||
|
d.emitStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateBankNoise folds a fresh bank-median reading into the broadband noise
|
||||||
|
// reference used by the absolute squelch.
|
||||||
|
func (d *Decoder) updateBankNoise(medianDB float64) {
|
||||||
|
if !d.haveBankNoise {
|
||||||
|
d.bankNoiseDB, d.haveBankNoise = medianDB, true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.bankNoiseDB += (medianDB - d.bankNoiseDB) * 0.2
|
||||||
|
}
|
||||||
|
|
||||||
|
// glitchHops is the debounce time in hops: ~0.3 dit, clamped to 1..3 hops
|
||||||
|
// (6–19 ms) so it neither swallows fast dits nor passes static crashes.
|
||||||
|
func (d *Decoder) glitchHops() int {
|
||||||
|
g := int(math.Round(0.3 * d.muDit / d.hopMs))
|
||||||
|
if g < 1 {
|
||||||
|
g = 1
|
||||||
|
}
|
||||||
|
if g > 3 {
|
||||||
|
g = 3
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
// timing runs the debounced mark/space state machine for one hop.
|
||||||
|
func (d *Decoder) timing() {
|
||||||
|
if d.rawKey == d.key {
|
||||||
|
// Agreement folds any pending flip back into the committed state:
|
||||||
|
// a sub-glitch dropout inside a dah (or spike inside a gap) vanishes.
|
||||||
|
d.stableHops += 1 + d.pendHops
|
||||||
|
d.pendHops = 0
|
||||||
|
} else {
|
||||||
|
d.pendHops++
|
||||||
|
if d.pendHops > d.glitchHops() {
|
||||||
|
seg := d.stableHops
|
||||||
|
wasMark := d.key
|
||||||
|
d.key = d.rawKey
|
||||||
|
d.stableHops = d.pendHops
|
||||||
|
d.pendHops = 0
|
||||||
|
if wasMark {
|
||||||
|
d.endMark(seg)
|
||||||
|
} else {
|
||||||
|
d.endSpace(seg)
|
||||||
|
}
|
||||||
|
if d.key {
|
||||||
|
d.charEmitted, d.wordEmitted = false, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !d.key {
|
||||||
|
d.spaceProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// endMark stores a finished key-down run for the character batch. Cluster
|
||||||
|
// updates deliberately do NOT happen here: attributing a mark to the dit or
|
||||||
|
// dah centre with the still-converging global boundary poisons the clusters
|
||||||
|
// (a dah-led character at an unexpected speed lands its dahs in the dit
|
||||||
|
// centre, which then oscillates). Both classification AND cluster updates
|
||||||
|
// happen per character in flushChar, where the batch's own contrast makes
|
||||||
|
// the attribution reliable.
|
||||||
|
func (d *Decoder) endMark(hops int) {
|
||||||
|
ms := float64(hops)*d.hopMs - d.biasMs // de-bias the window widening
|
||||||
|
if ms < 0.5*minDitMs {
|
||||||
|
return // debounce residue — not a credible element
|
||||||
|
}
|
||||||
|
d.elemMs = append(d.elemMs, ms)
|
||||||
|
// Bootstrap: a first mark SHORTER than the seeded dit can only be a dit of
|
||||||
|
// a faster sender — jump the estimate straight there instead of easing in.
|
||||||
|
if d.marksSeen == 0 && ms < d.muDit {
|
||||||
|
d.muDit = math.Max(ms, minDitMs)
|
||||||
|
}
|
||||||
|
d.marksSeen++
|
||||||
|
if len(d.elemMs) > 8 {
|
||||||
|
d.elemMs = d.elemMs[:0] // no Morse character is that long — noise run
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// endSpace runs when a mark begins: the finished gap, if it was clearly an
|
||||||
|
// inter-element one, is extra evidence for the dit length (gaps are often
|
||||||
|
// steadier than dits in hand keying).
|
||||||
|
func (d *Decoder) endSpace(hops int) {
|
||||||
|
ms := float64(hops)*d.hopMs + d.biasMs // gaps shrink by what marks gained
|
||||||
|
if d.marksSeen < 1 || ms < 0.35*d.muDit || ms > 1.7*d.muDit {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Early on, gaps are the FASTEST way to find the true dit length (the very
|
||||||
|
// first inter-element gap is one, whatever the first mark was); once the
|
||||||
|
// clusters have settled they are just a gentle refinement.
|
||||||
|
alpha := 0.08
|
||||||
|
if d.marksSeen < 8 {
|
||||||
|
alpha = 0.35
|
||||||
|
}
|
||||||
|
d.muDit += (ms - d.muDit) * alpha
|
||||||
|
d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaceProgress emits the pending character / word space LIVE once the current
|
||||||
|
// gap crosses each boundary (instead of waiting for the next mark), so text
|
||||||
|
// appears as it is sent.
|
||||||
|
func (d *Decoder) spaceProgress() {
|
||||||
|
gapMs := float64(d.stableHops)*d.hopMs + d.biasMs
|
||||||
|
if !d.charEmitted && gapMs > charGapDits*d.muDit {
|
||||||
|
d.flushChar()
|
||||||
|
d.charEmitted = true
|
||||||
|
}
|
||||||
|
if !d.wordEmitted && gapMs > wordGapDits*d.muDit {
|
||||||
|
if d.textSince && d.onChar != nil {
|
||||||
|
d.onChar(" ")
|
||||||
|
}
|
||||||
|
d.wordEmitted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushChar classifies the accumulated mark durations into dits/dahs and
|
||||||
|
// emits the character. Classification happens HERE, not as marks arrive: a
|
||||||
|
// character whose own marks are clearly bimodal carries its own dit/dah
|
||||||
|
// boundary (their geometric mean), which decodes correctly even before the
|
||||||
|
// global clusters have converged — the first character of an over included.
|
||||||
|
// Unimodal characters (EEE, TTT, 555…) fall back to the global boundary.
|
||||||
|
func (d *Decoder) flushChar() {
|
||||||
|
if len(d.elemMs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lo, hi := d.elemMs[0], d.elemMs[0]
|
||||||
|
for _, v := range d.elemMs[1:] {
|
||||||
|
lo = math.Min(lo, v)
|
||||||
|
hi = math.Max(hi, v)
|
||||||
|
}
|
||||||
|
b := math.Sqrt(d.muDit * d.muDah)
|
||||||
|
if hi >= 2*lo {
|
||||||
|
b = math.Sqrt(lo * hi) // the batch is bimodal: trust its own contrast
|
||||||
|
}
|
||||||
|
alpha := 0.18
|
||||||
|
if d.marksSeen <= 8 {
|
||||||
|
alpha = 0.45 // converge fast on a new sender
|
||||||
|
}
|
||||||
|
pat := make([]byte, len(d.elemMs))
|
||||||
|
for i, v := range d.elemMs {
|
||||||
|
if v < b {
|
||||||
|
pat[i] = '.'
|
||||||
|
d.muDit += (v - d.muDit) * alpha
|
||||||
|
} else {
|
||||||
|
pat[i] = '-'
|
||||||
|
d.muDah += (v - d.muDah) * alpha
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ratio guards: dah stays 2–4.8 dits; dit stays within speed limits.
|
||||||
|
d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
|
||||||
|
if d.muDah < 2.0*d.muDit {
|
||||||
|
d.muDah = 2.0 * d.muDit
|
||||||
|
}
|
||||||
|
if d.muDah > 4.8*d.muDit {
|
||||||
|
d.muDah = 4.8 * d.muDit
|
||||||
|
}
|
||||||
|
d.elemMs = d.elemMs[:0]
|
||||||
|
if c, ok := morse[string(pat)]; ok {
|
||||||
|
if d.onChar != nil {
|
||||||
|
d.onChar(string(c))
|
||||||
|
}
|
||||||
|
d.textSince = true
|
||||||
|
} else if len(pat) <= 7 {
|
||||||
|
// Morse-shaped but unknown → flag it; longer runs are noise, drop.
|
||||||
|
if d.onChar != nil {
|
||||||
|
d.onChar("?")
|
||||||
|
}
|
||||||
|
d.textSince = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushPending finishes the in-progress character and word at end-of-over
|
||||||
|
// (lock release), so the last word isn't left hanging until the next signal.
|
||||||
|
func (d *Decoder) flushPending() {
|
||||||
|
if !d.charEmitted {
|
||||||
|
d.flushChar()
|
||||||
|
d.charEmitted = true
|
||||||
|
}
|
||||||
|
if !d.wordEmitted && d.textSince && d.onChar != nil {
|
||||||
|
d.onChar(" ")
|
||||||
|
d.wordEmitted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Decoder) emitStatus() {
|
||||||
|
d.sinceStatus++
|
||||||
|
if d.sinceStatus < d.statusEvery || d.onStatus == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.sinceStatus = 0
|
||||||
|
wpm := 0
|
||||||
|
if d.muDit > 0 && (d.lockIdx >= 0 || d.targetHz.Load() > 0) {
|
||||||
|
wpm = int(math.Round(1200 / d.muDit))
|
||||||
|
}
|
||||||
|
d.onStatus(Status{
|
||||||
|
WPM: wpm,
|
||||||
|
Pitch: int(math.Round(d.lastPitch)),
|
||||||
|
Level: d.lastRMS,
|
||||||
|
Active: d.rawKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
package cwdecode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Synthesizer -----------------------------------------------------------
|
||||||
|
|
||||||
|
func charToMorse() map[byte]string {
|
||||||
|
m := map[byte]string{}
|
||||||
|
for code, ch := range morse {
|
||||||
|
m[ch] = code
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyMessage synthesizes keyed CW for msg with raised-cosine edges (5 ms), so
|
||||||
|
// the signal has realistic click-free envelopes rather than hard steps.
|
||||||
|
func keyMessage(msg string, fs, wpm int, pitch, amp float64) []int16 {
|
||||||
|
dot := fs * 1200 / (wpm * 1000) // samples per dit
|
||||||
|
edge := fs * 5 / 1000 // 5 ms shaping
|
||||||
|
c2m := charToMorse()
|
||||||
|
var out []float64
|
||||||
|
phase := 0.0
|
||||||
|
dphi := 2 * math.Pi * pitch / float64(fs)
|
||||||
|
|
||||||
|
tone := func(n int) {
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
g := 1.0
|
||||||
|
if i < edge {
|
||||||
|
g = 0.5 - 0.5*math.Cos(math.Pi*float64(i)/float64(edge))
|
||||||
|
} else if n-1-i < edge {
|
||||||
|
g = 0.5 - 0.5*math.Cos(math.Pi*float64(n-1-i)/float64(edge))
|
||||||
|
}
|
||||||
|
out = append(out, amp*g*math.Sin(phase))
|
||||||
|
phase += dphi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
silence := func(n int) {
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
out = append(out, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
silence(fs / 4) // lead-in for envelope warm-up
|
||||||
|
for i := 0; i < len(msg); i++ {
|
||||||
|
ch := msg[i]
|
||||||
|
if ch == ' ' {
|
||||||
|
silence(4 * dot) // + trailing 3 from the previous char = 7 total
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
code := c2m[ch]
|
||||||
|
for j := 0; j < len(code); j++ {
|
||||||
|
if code[j] == '.' {
|
||||||
|
tone(dot)
|
||||||
|
} else {
|
||||||
|
tone(3 * dot)
|
||||||
|
}
|
||||||
|
silence(dot)
|
||||||
|
}
|
||||||
|
silence(2 * dot) // + trailing element gap = 3 total
|
||||||
|
}
|
||||||
|
silence(fs / 2)
|
||||||
|
return toInt16(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toInt16(x []float64) []int16 {
|
||||||
|
out := make([]int16, len(x))
|
||||||
|
for i, v := range x {
|
||||||
|
if v > 32767 {
|
||||||
|
v = 32767
|
||||||
|
} else if v < -32768 {
|
||||||
|
v = -32768
|
||||||
|
}
|
||||||
|
out[i] = int16(v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func addNoise(s []int16, sigma float64, seed int64) []int16 {
|
||||||
|
r := rand.New(rand.NewSource(seed))
|
||||||
|
out := make([]int16, len(s))
|
||||||
|
for i, v := range s {
|
||||||
|
out[i] = int16(math.Max(-32768, math.Min(32767, float64(v)+r.NormFloat64()*sigma)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyQSB modulates the amplitude between lo..1.0 at rate Hz (slow fading).
|
||||||
|
func applyQSB(s []int16, fs int, rate, lo float64) []int16 {
|
||||||
|
out := make([]int16, len(s))
|
||||||
|
for i, v := range s {
|
||||||
|
g := lo + (1-lo)*(0.5+0.5*math.Sin(2*math.Pi*rate*float64(i)/float64(fs)))
|
||||||
|
out[i] = int16(float64(v) * g)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDropouts blanks brief windows (ms long) every period ms — static-crash
|
||||||
|
// style holes that land inside dahs and gaps alike.
|
||||||
|
func applyDropouts(s []int16, fs int, everyMs, holeMs int) []int16 {
|
||||||
|
out := make([]int16, len(s))
|
||||||
|
copy(out, s)
|
||||||
|
every := fs * everyMs / 1000
|
||||||
|
hole := fs * holeMs / 1000
|
||||||
|
for start := every; start+hole < len(out); start += every {
|
||||||
|
for i := start; i < start+hole; i++ {
|
||||||
|
out[i] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mix(a, b []int16) []int16 {
|
||||||
|
n := len(a)
|
||||||
|
if len(b) > n {
|
||||||
|
n = len(b)
|
||||||
|
}
|
||||||
|
out := make([]int16, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
var v int
|
||||||
|
if i < len(a) {
|
||||||
|
v += int(a[i])
|
||||||
|
}
|
||||||
|
if i < len(b) {
|
||||||
|
v += int(b[i])
|
||||||
|
}
|
||||||
|
if v > 32767 {
|
||||||
|
v = 32767
|
||||||
|
} else if v < -32768 {
|
||||||
|
v = -32768
|
||||||
|
}
|
||||||
|
out[i] = int16(v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// decode runs samples through a fresh decoder in live-sized chunks.
|
||||||
|
func decode(t *testing.T, samples []int16, targetHz int) string {
|
||||||
|
t.Helper()
|
||||||
|
var sb strings.Builder
|
||||||
|
d := New(16000, func(s string) { sb.WriteString(s) }, nil)
|
||||||
|
if targetHz > 0 {
|
||||||
|
d.SetTarget(targetHz)
|
||||||
|
}
|
||||||
|
for i := 0; i < len(samples); i += 256 {
|
||||||
|
end := i + 256
|
||||||
|
if end > len(samples) {
|
||||||
|
end = len(samples)
|
||||||
|
}
|
||||||
|
d.Process(samples[i:end])
|
||||||
|
}
|
||||||
|
return strings.ToUpper(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func wantContains(t *testing.T, got, want, label string) {
|
||||||
|
t.Helper()
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("%s: decoded %q, want it to contain %q", label, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tests -----------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestCleanSignalSpeeds(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
for _, wpm := range []int{12, 18, 25, 32, 40} {
|
||||||
|
got := decode(t, keyMessage("CQ TEST DE F4BPO K", fs, wpm, 700, 9000), 0)
|
||||||
|
wantContains(t, got, "CQ TEST DE F4BPO K", "clean @"+itoa(wpm)+"wpm")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOtherPitches(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
for _, pitch := range []float64{450, 600, 850} {
|
||||||
|
got := decode(t, keyMessage("PARIS PARIS", fs, 22, pitch, 9000), 0)
|
||||||
|
wantContains(t, got, "PARIS PARIS", "pitch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithNoise(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
clean := keyMessage("CQ CQ DE HB9HBY", fs, 22, 700, 9000)
|
||||||
|
noisy := addNoise(clean, 2000, 1) // ≈13 dB tone/noise in the audio band
|
||||||
|
got := decode(t, noisy, 0)
|
||||||
|
wantContains(t, got, "CQ CQ DE HB9HBY", "noise")
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSB fading between 35% and 100% amplitude — the adaptive dB envelope must
|
||||||
|
// ride it. The old linear envelope lost the faded halves entirely.
|
||||||
|
func TestQSBFading(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
clean := keyMessage("CQ CQ CQ DE F4BPO F4BPO", fs, 20, 700, 12000)
|
||||||
|
faded := applyQSB(clean, fs, 0.4, 0.35)
|
||||||
|
got := decode(t, faded, 0)
|
||||||
|
wantContains(t, got, "DE F4BPO", "qsb")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brief 10 ms holes punched every 150 ms — they land inside dahs. Without the
|
||||||
|
// two-sided debounce every hit dah shatters into dits (the old decoder's
|
||||||
|
// single worst failure on real signals).
|
||||||
|
func TestDropoutsInsideDahs(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
clean := keyMessage("TEST TEST TEST", fs, 18, 700, 9000)
|
||||||
|
holed := applyDropouts(clean, fs, 150, 10)
|
||||||
|
got := decode(t, holed, 0)
|
||||||
|
wantContains(t, got, "TEST TEST", "dropouts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// QRM: a second, slightly weaker keyed signal at 950 Hz. The pitch lock must
|
||||||
|
// hold the 700 Hz target and ignore the interferer.
|
||||||
|
func TestQRMAutoLock(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
target := keyMessage("PARIS PARIS PARIS", fs, 20, 700, 9000)
|
||||||
|
qrm := keyMessage("QRZ QRZ QRZ QRZ QRZ", fs, 26, 950, 5000)
|
||||||
|
got := decode(t, mix(target, qrm), 0)
|
||||||
|
wantContains(t, got, "PARIS", "qrm-auto")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Targeted mode: with two comparable signals, SetTarget must decode the chosen
|
||||||
|
// one even though the other is as strong.
|
||||||
|
func TestQRMTargeted(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
want := keyMessage("SOS SOS SOS", fs, 20, 600, 8000)
|
||||||
|
other := keyMessage("QRL QRL QRL QRL", fs, 24, 900, 8000)
|
||||||
|
got := decode(t, mix(want, other), 600)
|
||||||
|
wantContains(t, got, "SOS SOS", "qrm-target")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure noise must stay silent: the squelch keys nothing, so no text at all.
|
||||||
|
func TestNoiseOnlySquelch(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
noise := addNoise(make([]int16, fs*6), 3000, 7)
|
||||||
|
got := strings.TrimSpace(decode(t, noise, 0))
|
||||||
|
if len(got) > 2 { // tolerate at most a stray flagged char
|
||||||
|
t.Fatalf("squelch: decoded %q from pure noise, want (almost) nothing", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyMessageJitter synthesizes hand-sent CW: every element and gap duration
|
||||||
|
// is jittered (elements ±je, gaps ±jg, uniform), like a human fist.
|
||||||
|
func keyMessageJitter(msg string, fs, wpm int, pitch, amp, je, jg float64, seed int64) []int16 {
|
||||||
|
r := rand.New(rand.NewSource(seed))
|
||||||
|
dot := float64(fs) * 1200 / (float64(wpm) * 1000)
|
||||||
|
edge := fs * 5 / 1000
|
||||||
|
c2m := charToMorse()
|
||||||
|
var out []float64
|
||||||
|
phase := 0.0
|
||||||
|
dphi := 2 * math.Pi * pitch / float64(fs)
|
||||||
|
jit := func(n float64, j float64) int { return int(n * (1 + (r.Float64()*2-1)*j)) }
|
||||||
|
tone := func(n int) {
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
g := 1.0
|
||||||
|
if i < edge {
|
||||||
|
g = 0.5 - 0.5*math.Cos(math.Pi*float64(i)/float64(edge))
|
||||||
|
} else if n-1-i < edge {
|
||||||
|
g = 0.5 - 0.5*math.Cos(math.Pi*float64(n-1-i)/float64(edge))
|
||||||
|
}
|
||||||
|
out = append(out, amp*g*math.Sin(phase))
|
||||||
|
phase += dphi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
silence := func(n int) {
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
out = append(out, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
silence(fs / 4)
|
||||||
|
for i := 0; i < len(msg); i++ {
|
||||||
|
ch := msg[i]
|
||||||
|
if ch == ' ' {
|
||||||
|
silence(jit(4*dot, jg))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
code := c2m[ch]
|
||||||
|
for j := 0; j < len(code); j++ {
|
||||||
|
if code[j] == '.' {
|
||||||
|
tone(jit(dot, je))
|
||||||
|
} else {
|
||||||
|
tone(jit(3*dot, je))
|
||||||
|
}
|
||||||
|
silence(jit(dot, jg))
|
||||||
|
}
|
||||||
|
silence(jit(2*dot, jg))
|
||||||
|
}
|
||||||
|
silence(fs / 2)
|
||||||
|
return toInt16(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand keying: ±20% element jitter, ±25% gap jitter — a sloppy but readable
|
||||||
|
// human fist. The batch classifier and the gap-fed dit tracking must ride it.
|
||||||
|
func TestHandKeying(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
for seed := int64(1); seed <= 3; seed++ {
|
||||||
|
s := keyMessageJitter("CQ CQ DE HB9HBY HB9HBY K", fs, 22, 700, 9000, 0.20, 0.25, seed)
|
||||||
|
got := decode(t, s, 0)
|
||||||
|
wantContains(t, got, "HB9HBY", "hand-keying")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speed change mid-over: the cluster tracker must follow 25 → 15 WPM.
|
||||||
|
func TestSpeedChange(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
fast := keyMessage("CQ CQ CQ DE F4BPO", fs, 25, 700, 9000)
|
||||||
|
slow := keyMessage("UR RST 599 599", fs, 15, 700, 9000)
|
||||||
|
got := decode(t, append(fast, slow...), 0)
|
||||||
|
wantContains(t, got, "F4BPO", "speed-fast-part")
|
||||||
|
wantContains(t, got, "599", "speed-slow-part")
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var b [8]byte
|
||||||
|
i := len(b)
|
||||||
|
for n > 0 {
|
||||||
|
i--
|
||||||
|
b[i] = byte('0' + n%10)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
return string(b[i:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package cwdecode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDebugTrace prints the element stream for a chosen case — a development
|
||||||
|
// aid, not an assertion test. Run with: go test -run TestDebugTrace -v
|
||||||
|
func TestDebugTrace(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("debug aid")
|
||||||
|
}
|
||||||
|
const fs = 16000
|
||||||
|
samples := keyMessage("CQ TEST DE F4BPO K", fs, 40, 700, 9000)
|
||||||
|
var d *Decoder
|
||||||
|
d = New(fs, func(s string) { fmt.Printf("EMIT %q (muDit=%.0f muDah=%.0f)\n", s, d.muDit, d.muDah) }, nil)
|
||||||
|
lastMarks := 0
|
||||||
|
for i := 0; i < len(samples); i += 256 {
|
||||||
|
end := i + 256
|
||||||
|
if end > len(samples) {
|
||||||
|
end = len(samples)
|
||||||
|
}
|
||||||
|
d.Process(samples[i:end])
|
||||||
|
if d.marksSeen != lastMarks {
|
||||||
|
lastMarks = d.marksSeen
|
||||||
|
fmt.Printf("mark#%d elems=%v muDit=%.0f muDah=%.0f\n", d.marksSeen, d.elemMs, d.muDit, d.muDah)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -207,9 +207,58 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
|||||||
Dialect = "sqlite" // migration failed; we'll fall back to SQLite
|
Dialect = "sqlite" // migration failed; we'll fall back to SQLite
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Guarantee the schema can store non-Latin text (Cyrillic, Polish ł, …).
|
||||||
|
// Best-effort: never block access to the logbook over the charset probe.
|
||||||
|
if err := ensureMySQLUTF8MB4(conn, name); err != nil {
|
||||||
|
fmt.Printf("OpsLog: utf8mb4 conversion: %v\n", err)
|
||||||
|
}
|
||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureMySQLUTF8MB4 makes the database default and every OpsLog table utf8mb4,
|
||||||
|
// converting any that isn't. It repairs databases pre-created as latin1 or
|
||||||
|
// utf8mb3 by a hosting panel (cPanel/o2switch default), where OpsLog's
|
||||||
|
// "CREATE DATABASE IF NOT EXISTS … utf8mb4" was a no-op and the tables inherited
|
||||||
|
// a charset that can't store non-Latin callsigns/names/QTHs — the MySQL 1366
|
||||||
|
// "Incorrect string value: '\xD0\xAF'" (Cyrillic Я) seen updating a QSO from QRZ.
|
||||||
|
//
|
||||||
|
// Idempotent and cheap on a healthy database: one information_schema probe finds
|
||||||
|
// the tables with a non-utf8mb4 text column, and only those are converted (none
|
||||||
|
// on an already-correct DB). Conversion is safe for the wide qso table because
|
||||||
|
// the bulk of its columns are off-page TEXT (see the row-size note above); only
|
||||||
|
// the ~20 indexed VARCHARs sit in-row, well under InnoDB's 65535-byte limit even
|
||||||
|
// at 4 bytes/char.
|
||||||
|
func ensureMySQLUTF8MB4(conn *sql.DB, dbName string) error {
|
||||||
|
// Fix the database default so any FUTURE table is utf8mb4 too.
|
||||||
|
_, _ = conn.Exec("ALTER DATABASE `" + dbName + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
|
||||||
|
|
||||||
|
rows, err := conn.Query(`
|
||||||
|
SELECT DISTINCT TABLE_NAME
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = ?
|
||||||
|
AND CHARACTER_SET_NAME IS NOT NULL
|
||||||
|
AND CHARACTER_SET_NAME <> 'utf8mb4'`, dbName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("probe charsets: %w", err)
|
||||||
|
}
|
||||||
|
var tables []string
|
||||||
|
for rows.Next() {
|
||||||
|
var t string
|
||||||
|
if err := rows.Scan(&t); err == nil && validDBIdent(t) {
|
||||||
|
tables = append(tables, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
var firstErr error
|
||||||
|
for _, t := range tables {
|
||||||
|
if _, err := conn.Exec("ALTER TABLE `" + t + "` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil && firstErr == nil {
|
||||||
|
firstErr = fmt.Errorf("convert %s: %w", t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
// isFreshMySQL reports whether the database has no OpsLog schema yet (no qso
|
// isFreshMySQL reports whether the database has no OpsLog schema yet (no qso
|
||||||
// table), so the baseline fast-path applies. A partially-migrated database is
|
// table), so the baseline fast-path applies. A partially-migrated database is
|
||||||
// NOT fresh and goes through the incremental migrator.
|
// NOT fresh and goes through the incremental migrator.
|
||||||
|
|||||||
+14
-2
@@ -41,6 +41,16 @@ func (c Config) opts() []mail.Option {
|
|||||||
|
|
||||||
// Send delivers a plain-text email to `to`, optionally attaching a file.
|
// Send delivers a plain-text email to `to`, optionally attaching a file.
|
||||||
func Send(cfg Config, to, subject, body, attachPath string) error {
|
func Send(cfg Config, to, subject, body, attachPath string) error {
|
||||||
|
var attach []string
|
||||||
|
if attachPath != "" {
|
||||||
|
attach = []string{attachPath}
|
||||||
|
}
|
||||||
|
return SendFiles(cfg, to, subject, body, attach)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendFiles is like Send but attaches any number of files (missing paths are
|
||||||
|
// skipped by the mail library at send time).
|
||||||
|
func SendFiles(cfg Config, to, subject, body string, attachPaths []string) error {
|
||||||
if cfg.Host == "" {
|
if cfg.Host == "" {
|
||||||
return fmt.Errorf("SMTP server not configured")
|
return fmt.Errorf("SMTP server not configured")
|
||||||
}
|
}
|
||||||
@@ -65,8 +75,10 @@ func Send(cfg Config, to, subject, body, attachPath string) error {
|
|||||||
}
|
}
|
||||||
m.Subject(subject)
|
m.Subject(subject)
|
||||||
m.SetBodyString(mail.TypeTextPlain, body)
|
m.SetBodyString(mail.TypeTextPlain, body)
|
||||||
if attachPath != "" {
|
for _, p := range attachPaths {
|
||||||
m.AttachFile(attachPath)
|
if p != "" {
|
||||||
|
m.AttachFile(p)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
client, err := mail.NewClient(cfg.Host, cfg.opts()...)
|
client, err := mail.NewClient(cfg.Host, cfg.opts()...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ type ProfileDB struct {
|
|||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Database string `json:"database"`
|
Database string `json:"database"`
|
||||||
|
// Path is a per-profile SQLite logbook FILE, distinct from the shared app
|
||||||
|
// database (opslog.db, which always holds settings + profiles). Empty =
|
||||||
|
// the shared database is used as the logbook (the historical default). Set =
|
||||||
|
// this profile's QSOs live in their own .db, so a visiting operator's contacts
|
||||||
|
// don't mix into yours and switching it never touches your config. Only used
|
||||||
|
// when Backend != "mysql".
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile is one operating configuration. A user typically keeps a few:
|
// Profile is one operating configuration. A user typically keeps a few:
|
||||||
|
|||||||
@@ -366,6 +366,42 @@ func decodeExtras(s string) map[string]string {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DistinctExtraKeys returns the set of keys present across stored QSO extras
|
||||||
|
// (the non-promoted ADIF tags kept verbatim), scanning the most recent `sample`
|
||||||
|
// rows that have any extras. Capped so it stays fast on a large remote logbook —
|
||||||
|
// the extras vocabulary is small and stable, so a recent sample finds it all.
|
||||||
|
func (r *Repo) DistinctExtraKeys(ctx context.Context, sample int) ([]string, error) {
|
||||||
|
if sample <= 0 {
|
||||||
|
sample = 5000
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT extras_json FROM qso
|
||||||
|
WHERE extras_json IS NOT NULL AND extras_json <> '' AND extras_json <> '{}'
|
||||||
|
ORDER BY id DESC LIMIT ?`, sample)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var s sql.NullString
|
||||||
|
if err := rows.Scan(&s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for k := range decodeExtras(s.String) {
|
||||||
|
seen[k] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for k := range seen {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Add inserts a QSO and returns its ID.
|
// Add inserts a QSO and returns its ID.
|
||||||
func (r *Repo) Add(ctx context.Context, q QSO) (int64, error) {
|
func (r *Repo) Add(ctx context.Context, q QSO) (int64, error) {
|
||||||
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
|
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
|
||||||
@@ -792,6 +828,31 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BulkSetFrequency sets freq_hz AND band together on every listed QSO. Kept
|
||||||
|
// separate from BulkSetField because frequency is numeric and must keep the band
|
||||||
|
// consistent — the main use is fixing a batch that was logged on a stale/default
|
||||||
|
// frequency after CAT dropped. freqHz "" is not allowed (caller validates).
|
||||||
|
func (r *Repo) BulkSetFrequency(ctx context.Context, ids []int64, freqHz int64, band string) (int64, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
ph := make([]string, len(ids))
|
||||||
|
args := make([]any, 0, len(ids)+3)
|
||||||
|
args = append(args, freqHz, band, db.NowISO())
|
||||||
|
for i, id := range ids {
|
||||||
|
ph[i] = "?"
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
res, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE qso SET freq_hz = ?, band = ?, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
||||||
|
args...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("bulk set frequency: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetExtra merges ONE key into a QSO's extras JSON without rewriting the rest of
|
// SetExtra merges ONE key into a QSO's extras JSON without rewriting the rest of
|
||||||
// the row. A slow caller that only wants to stamp its own app field (e-mailing a
|
// the row. A slow caller that only wants to stamp its own app field (e-mailing a
|
||||||
// QSL card, saving a recording) must not do a full-row Update: the row it read may
|
// QSL card, saving a recording) must not do a full-row Update: the row it read may
|
||||||
@@ -1345,6 +1406,10 @@ type WorkedBefore struct {
|
|||||||
DXCCModes []string `json:"dxcc_modes"`
|
DXCCModes []string `json:"dxcc_modes"`
|
||||||
DXCCBandModes []BandMode `json:"dxcc_band_modes"`
|
DXCCBandModes []BandMode `json:"dxcc_band_modes"`
|
||||||
|
|
||||||
|
// MWRank is the ClubLog "Most Wanted" rank for this entity (1 = most wanted),
|
||||||
|
// 0 when unknown or the feature is off. Populated by the app layer, not here.
|
||||||
|
MWRank int `json:"mw_rank,omitempty"`
|
||||||
|
|
||||||
// Status grid driving the band×class matrix in the UI. One entry per
|
// Status grid driving the band×class matrix in the UI. One entry per
|
||||||
// (band, class) where ANY QSO exists in this DXCC. Only the highest
|
// (band, class) where ANY QSO exists in this DXCC. Only the highest
|
||||||
// status for that cell is kept (call_c > call_w > dxcc_c > dxcc_w).
|
// status for that cell is kept (call_c > call_w > dxcc_c > dxcc_w).
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
package steppir
|
package steppir
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -84,6 +85,12 @@ type Client struct {
|
|||||||
lastStatus *Status
|
lastStatus *Status
|
||||||
lastSetKHz int
|
lastSetKHz int
|
||||||
|
|
||||||
|
// lastRaw holds the previous raw status frame so we only log a status line
|
||||||
|
// when the controller's reply actually changes — enough to diagnose a stuck
|
||||||
|
// "motors moving" read (which drives the app's TX-inhibit interlock) without
|
||||||
|
// spamming the log every 2 s poll.
|
||||||
|
lastRaw []byte
|
||||||
|
|
||||||
// A just-commanded direction is held until the controller's poll reports it —
|
// A just-commanded direction is held until the controller's poll reports it —
|
||||||
// the motors take a second or two, and a stale poll would otherwise snap the
|
// the motors take a second or two, and a stale poll would otherwise snap the
|
||||||
// UI back. Same trick as the Ultrabeam client.
|
// UI back. Same trick as the Ultrabeam client.
|
||||||
@@ -242,7 +249,17 @@ func (c *Client) queryStatus() (*Status, error) {
|
|||||||
if _, err := io.ReadFull(conn, buf); err != nil {
|
if _, err := io.ReadFull(conn, buf); err != nil {
|
||||||
return nil, fmt.Errorf("read status: %w", err)
|
return nil, fmt.Errorf("read status: %w", err)
|
||||||
}
|
}
|
||||||
return parseStatus(buf)
|
st, err := parseStatus(buf)
|
||||||
|
// Log the raw frame + decode whenever it changes. The motor byte (buf[6]) is
|
||||||
|
// what decides st.MotorsMoving, and that in turn drives the app's "block TX
|
||||||
|
// while moving" interlock — so if a controller (e.g. with its INHIBIT engaged)
|
||||||
|
// reports a byte we misread as perpetual motion, this line makes it visible.
|
||||||
|
if err == nil && !bytes.Equal(buf, c.lastRaw) {
|
||||||
|
c.lastRaw = append(c.lastRaw[:0], buf...)
|
||||||
|
log.Printf("steppir: status ← % X (freq=%d kHz dir=%d moving=%d motorByte=0x%02X dirByte=0x%02X)",
|
||||||
|
buf, st.Frequency, st.Direction, st.MotorsMoving, buf[6], buf[7])
|
||||||
|
}
|
||||||
|
return st, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseStatus decodes an 11-byte status frame.
|
// parseStatus decodes an 11-byte status frame.
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package winkeyer
|
||||||
|
|
||||||
|
// linectl_other.go — non-Windows DTR/RTS line control via go.bug.st/serial. On
|
||||||
|
// Linux/macOS the SetDTR/SetRTS ioctls control the lines independently, so the
|
||||||
|
// CP210x-on-Windows problem that motivated the native path (see linectl_windows.go)
|
||||||
|
// doesn't apply. OpsLog ships on Windows; this keeps the package building elsewhere.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
type serialLineCtl struct {
|
||||||
|
port serial.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
func openLineCtl(port string) (lineCtl, error) {
|
||||||
|
if strings.TrimSpace(port) == "" {
|
||||||
|
return nil, fmt.Errorf("no serial port")
|
||||||
|
}
|
||||||
|
p, err := serial.Open(port, &serial.Mode{BaudRate: 9600, DataBits: 8, Parity: serial.NoParity, StopBits: serial.OneStopBit})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open %s: %w", port, err)
|
||||||
|
}
|
||||||
|
c := &serialLineCtl{port: p}
|
||||||
|
_ = c.setDTR(false)
|
||||||
|
_ = c.setRTS(false)
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *serialLineCtl) setDTR(on bool) error { return c.port.SetDTR(on) }
|
||||||
|
func (c *serialLineCtl) setRTS(on bool) error { return c.port.SetRTS(on) }
|
||||||
|
func (c *serialLineCtl) close() error { return c.port.Close() }
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package winkeyer
|
||||||
|
|
||||||
|
// linectl_windows.go — DTR/RTS line control via EscapeCommFunction, the direct
|
||||||
|
// Win32 method N1MM/WSJT/fldigi use. It sets ONE line at a time without a
|
||||||
|
// GetCommState/SetCommState round-trip, so a held RTS (PTT) is NOT disturbed when
|
||||||
|
// DTR (CW) is toggled. go.bug.st/serial drives the lines via SetCommState, which
|
||||||
|
// on USB-serial adapters (the SCU-17's CP210x, FTDI) drops the held RTS the moment
|
||||||
|
// DTR changes — the "rig drops to RX between words" bug this fixes.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
type winLineCtl struct {
|
||||||
|
h windows.Handle
|
||||||
|
}
|
||||||
|
|
||||||
|
// openLineCtl opens a COM port for line control only (no data I/O).
|
||||||
|
func openLineCtl(port string) (lineCtl, error) {
|
||||||
|
name := strings.TrimSpace(port)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("no serial port")
|
||||||
|
}
|
||||||
|
// The \\.\ prefix is required for COM10+ and harmless for COM1-9.
|
||||||
|
if !strings.HasPrefix(name, `\\.\`) {
|
||||||
|
name = `\\.\` + name
|
||||||
|
}
|
||||||
|
p, err := windows.UTF16PtrFromString(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h, err := windows.CreateFile(p,
|
||||||
|
windows.GENERIC_READ|windows.GENERIC_WRITE, 0, nil,
|
||||||
|
windows.OPEN_EXISTING, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open %s: %w", port, err)
|
||||||
|
}
|
||||||
|
// A minimal DCB so the driver is in a sane state. Baud is irrelevant for pure
|
||||||
|
// line control, but leaving fDtr/fRtsControl at DISABLE means only our explicit
|
||||||
|
// EscapeCommFunction calls drive the lines.
|
||||||
|
var dcb windows.DCB
|
||||||
|
if windows.GetCommState(h, &dcb) == nil {
|
||||||
|
dcb.BaudRate = 9600
|
||||||
|
dcb.ByteSize = 8
|
||||||
|
dcb.Parity = 0 // NOPARITY
|
||||||
|
dcb.StopBits = 0 // ONESTOPBIT
|
||||||
|
// Clear DTR (bits 4-5) and RTS (bits 12-13) control fields → DISABLE, so the
|
||||||
|
// lines idle low until we assert them, and no hardware flow control fights us.
|
||||||
|
dcb.Flags &^= 0x00000030
|
||||||
|
dcb.Flags &^= 0x00003000
|
||||||
|
_ = windows.SetCommState(h, &dcb)
|
||||||
|
}
|
||||||
|
c := &winLineCtl{h: h}
|
||||||
|
// Start both lines low (inactive) — the keyer's idle() will do this too.
|
||||||
|
_ = c.setDTR(false)
|
||||||
|
_ = c.setRTS(false)
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *winLineCtl) setDTR(on bool) error {
|
||||||
|
f := uint32(windows.CLRDTR)
|
||||||
|
if on {
|
||||||
|
f = windows.SETDTR
|
||||||
|
}
|
||||||
|
return windows.EscapeCommFunction(c.h, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *winLineCtl) setRTS(on bool) error {
|
||||||
|
f := uint32(windows.CLRRTS)
|
||||||
|
if on {
|
||||||
|
f = windows.SETRTS
|
||||||
|
}
|
||||||
|
return windows.EscapeCommFunction(c.h, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *winLineCtl) close() error { return windows.CloseHandle(c.h) }
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
package winkeyer
|
||||||
|
|
||||||
|
// serialcw.go — CW keying by toggling a serial control line, the "hardware CW
|
||||||
|
// keying" method N1MM / WSJT / fldigi use with a Yaesu SCU-17 and similar
|
||||||
|
// interfaces (DTR = CW key, RTS = PTT). No K1EL WinKeyer chip involved: the PC
|
||||||
|
// bit-bangs the Morse itself on the DTR (or RTS) line at the configured WPM.
|
||||||
|
//
|
||||||
|
// A single worker goroutine consumes queued text and keys it element by element,
|
||||||
|
// so Send returns immediately (like the WinKeyer's buffered send) and Stop can
|
||||||
|
// abort mid-message. Speed changes apply live between characters. PTT (when
|
||||||
|
// enabled) is asserted on the OTHER line for the whole transmission plus a
|
||||||
|
// lead-in / tail, and held through a short hang so send-on-type doesn't chatter.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lineCtl drives a serial port's DTR and RTS control lines. On Windows it uses
|
||||||
|
// EscapeCommFunction (SETDTR/CLRDTR/SETRTS/CLRRTS) — the direct method N1MM/WSJT
|
||||||
|
// use, reliable on USB-serial adapters (CP210x/FTDI). go.bug.st/serial drives the
|
||||||
|
// lines via SetCommState instead, which on a CP210x drops the held RTS (PTT) as
|
||||||
|
// soon as DTR (CW) is toggled — the "rig drops to RX between words" bug. See
|
||||||
|
// linectl_windows.go / linectl_other.go.
|
||||||
|
type lineCtl interface {
|
||||||
|
setDTR(on bool) error
|
||||||
|
setRTS(on bool) error
|
||||||
|
close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// morseTable maps a keyable character to its Morse element string (. = dit,
|
||||||
|
// - = dah). Mirrors winkeyer.allowedCW.
|
||||||
|
var morseTable = map[rune]string{
|
||||||
|
'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': "----.",
|
||||||
|
'.': ".-.-.-", ',': "--..--", '?': "..--..", '/': "-..-.", '=': "-...-",
|
||||||
|
'+': ".-.-.", '-': "-....-", ':': "---...", '(': "-.--.", ')': "-.--.-",
|
||||||
|
';': "-.-.-.", '"': ".-..-.", '\'': ".----.", '@': ".--.-.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// serialKeyer bit-bangs CW on a serial control line. Owned by a winkeyer.Manager
|
||||||
|
// while Type == "serial"; all keying happens in run(). The line-control options
|
||||||
|
// (key line, invert, PTT, lead/tail) are atomic so ApplyConfig can update them
|
||||||
|
// LIVE — e.g. ticking "Key PTT line" takes effect without reconnecting the keyer.
|
||||||
|
type serialKeyer struct {
|
||||||
|
line lineCtl
|
||||||
|
keyDTR atomic.Bool // true: CW on DTR, PTT on RTS (N1MM default). false: swapped.
|
||||||
|
invert atomic.Bool // true: active-LOW — asserting a line means driving it LOW
|
||||||
|
usePTT atomic.Bool // hold the PTT line for the whole transmission
|
||||||
|
leadMs atomic.Int32 // PTT lead-in
|
||||||
|
tailMs atomic.Int32 // PTT hold (hang) after the last element
|
||||||
|
onBusy func(bool)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
wpm int
|
||||||
|
farns int
|
||||||
|
abort chan struct{} // recreated by Stop; keying aborts when it closes
|
||||||
|
|
||||||
|
in chan string
|
||||||
|
stop chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSerialKeyer(line lineCtl, cfg Config, onBusy func(bool)) *serialKeyer {
|
||||||
|
k := &serialKeyer{
|
||||||
|
line: line,
|
||||||
|
onBusy: onBusy,
|
||||||
|
wpm: cfg.WPM,
|
||||||
|
farns: cfg.Farnsworth,
|
||||||
|
abort: make(chan struct{}),
|
||||||
|
in: make(chan string, 256),
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
k.keyDTR.Store(!strings.EqualFold(strings.TrimSpace(cfg.CWKeyLine), "rts")) // default DTR=CW
|
||||||
|
k.invert.Store(cfg.CWInvert)
|
||||||
|
k.usePTT.Store(cfg.UsePTT)
|
||||||
|
k.leadMs.Store(int32(cfg.LeadInMs))
|
||||||
|
k.tailMs.Store(int32(cfg.TailMs))
|
||||||
|
k.idle() // start inactive: key up, PTT off
|
||||||
|
go k.run()
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyConfig live-updates the line-control options (no port reopen), so a
|
||||||
|
// settings change is felt from the next character without a reconnect. Speed and
|
||||||
|
// Farnsworth are updated too. keyText re-reads these per element.
|
||||||
|
func (k *serialKeyer) ApplyConfig(cfg Config) {
|
||||||
|
k.keyDTR.Store(!strings.EqualFold(strings.TrimSpace(cfg.CWKeyLine), "rts"))
|
||||||
|
k.invert.Store(cfg.CWInvert)
|
||||||
|
k.usePTT.Store(cfg.UsePTT)
|
||||||
|
k.leadMs.Store(int32(cfg.LeadInMs))
|
||||||
|
k.tailMs.Store(int32(cfg.TailMs))
|
||||||
|
k.mu.Lock()
|
||||||
|
k.wpm = cfg.WPM
|
||||||
|
k.farns = cfg.Farnsworth
|
||||||
|
k.mu.Unlock()
|
||||||
|
k.idle() // re-assert idle lines with any new polarity/key-line choice
|
||||||
|
}
|
||||||
|
|
||||||
|
// level maps a logical "active" state to the physical line level, honouring the
|
||||||
|
// invert (active-LOW) option: normally active = HIGH, inverted active = LOW.
|
||||||
|
func (k *serialKeyer) level(active bool) bool { return active != k.invert.Load() }
|
||||||
|
|
||||||
|
// setKey raises/lowers the CW key line; setPTT the PTT line (only when enabled).
|
||||||
|
func (k *serialKeyer) setKey(down bool) {
|
||||||
|
if k.keyDTR.Load() {
|
||||||
|
_ = k.line.setDTR(k.level(down))
|
||||||
|
} else {
|
||||||
|
_ = k.line.setRTS(k.level(down))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (k *serialKeyer) setPTT(on bool) {
|
||||||
|
if !k.usePTT.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if k.keyDTR.Load() {
|
||||||
|
_ = k.line.setRTS(k.level(on))
|
||||||
|
} else {
|
||||||
|
_ = k.line.setDTR(k.level(on))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// idle drives BOTH lines to their inactive level (key up, PTT off), respecting
|
||||||
|
// the invert option — so an active-LOW interface isn't left keyed at rest.
|
||||||
|
func (k *serialKeyer) idle() {
|
||||||
|
_ = k.line.setDTR(k.level(false))
|
||||||
|
_ = k.line.setRTS(k.level(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSpeed / Send / Stop mirror the WinKeyer manager's control surface.
|
||||||
|
func (k *serialKeyer) SetSpeed(wpm int) {
|
||||||
|
k.mu.Lock()
|
||||||
|
k.wpm = wpm
|
||||||
|
k.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *serialKeyer) Send(text string) {
|
||||||
|
select {
|
||||||
|
case k.in <- text:
|
||||||
|
default: // queue full (pathological) — drop rather than block the app binding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop aborts the character being keyed and flushes anything queued.
|
||||||
|
func (k *serialKeyer) Stop() {
|
||||||
|
k.mu.Lock()
|
||||||
|
close(k.abort)
|
||||||
|
k.abort = make(chan struct{})
|
||||||
|
k.mu.Unlock()
|
||||||
|
for drained := false; !drained; {
|
||||||
|
select {
|
||||||
|
case <-k.in:
|
||||||
|
default:
|
||||||
|
drained = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
k.setKey(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *serialKeyer) Close() {
|
||||||
|
close(k.stop)
|
||||||
|
<-k.done
|
||||||
|
_ = k.line.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *serialKeyer) run() {
|
||||||
|
defer close(k.done)
|
||||||
|
defer k.idle()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-k.stop:
|
||||||
|
return
|
||||||
|
case s := <-k.in:
|
||||||
|
k.onBusy(true)
|
||||||
|
// Diagnostic: confirms whether PTT is actually held for the whole macro
|
||||||
|
// (a rig dropping to RX between words = usePTT off, or the interface's
|
||||||
|
// PTT line isn't wired / honoured in CW).
|
||||||
|
applog.Printf("winkeyer serial: TX %.40q ptt=%v line=%s lead=%dms tail=%dms",
|
||||||
|
s, k.usePTT.Load(), map[bool]string{true: "DTR-CW/RTS-PTT", false: "RTS-CW/DTR-PTT"}[k.keyDTR.Load()],
|
||||||
|
k.leadMs.Load(), k.tailMs.Load())
|
||||||
|
k.setPTT(true)
|
||||||
|
k.sleep(time.Duration(k.leadMs.Load()) * time.Millisecond)
|
||||||
|
k.keyText(s)
|
||||||
|
hang := time.Duration(k.tailMs.Load()) * time.Millisecond
|
||||||
|
if hang <= 0 {
|
||||||
|
hang = 5 * time.Millisecond
|
||||||
|
}
|
||||||
|
hold: // hold PTT briefly so back-to-back sends (send-on-type) don't chatter
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-k.stop:
|
||||||
|
k.idle()
|
||||||
|
k.onBusy(false)
|
||||||
|
return
|
||||||
|
case s2 := <-k.in:
|
||||||
|
k.keyText(s2)
|
||||||
|
case <-time.After(hang):
|
||||||
|
break hold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
k.setPTT(false)
|
||||||
|
k.onBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyText keys one string. Element gaps use the character speed (WPM); the
|
||||||
|
// inter-character (3-unit) and inter-word (7-unit) gaps use the Farnsworth speed
|
||||||
|
// when one is set, so slow-copy CW keeps full-speed characters with wider spacing.
|
||||||
|
func (k *serialKeyer) keyText(s string) {
|
||||||
|
k.mu.Lock()
|
||||||
|
abort := k.abort
|
||||||
|
k.mu.Unlock()
|
||||||
|
for _, r := range strings.ToUpper(s) {
|
||||||
|
select {
|
||||||
|
case <-abort:
|
||||||
|
k.setKey(false)
|
||||||
|
return
|
||||||
|
case <-k.stop:
|
||||||
|
k.setKey(false)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
ditW, ditF := k.dits()
|
||||||
|
if r == ' ' {
|
||||||
|
// Word gap: 7 units total. A 3-unit inter-char gap was already emitted
|
||||||
|
// after the previous character, so add 4 more.
|
||||||
|
if !k.gap(4*ditF, abort) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
code, ok := morseTable[r]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for j, el := range code {
|
||||||
|
if el == '.' {
|
||||||
|
if !k.mark(ditW, abort) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !k.mark(3*ditW, abort) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if j < len(code)-1 { // intra-character (element) gap: 1 unit
|
||||||
|
if !k.gap(ditW, abort) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !k.gap(3*ditF, abort) { // inter-character gap: 3 units
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mark holds the key down for d; gap holds it up for d. Both abort cleanly
|
||||||
|
// (leaving the key UP) when Stop closes abort or the keyer shuts down.
|
||||||
|
func (k *serialKeyer) mark(d time.Duration, abort chan struct{}) bool {
|
||||||
|
k.setKey(true)
|
||||||
|
ok := k.wait(d, abort)
|
||||||
|
k.setKey(false)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
func (k *serialKeyer) gap(d time.Duration, abort chan struct{}) bool { return k.wait(d, abort) }
|
||||||
|
|
||||||
|
func (k *serialKeyer) wait(d time.Duration, abort chan struct{}) bool {
|
||||||
|
if d <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
t := time.NewTimer(d)
|
||||||
|
defer t.Stop()
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
return true
|
||||||
|
case <-abort:
|
||||||
|
return false
|
||||||
|
case <-k.stop:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sleep is a non-abortable pause (used for the short PTT lead-in).
|
||||||
|
func (k *serialKeyer) sleep(d time.Duration) {
|
||||||
|
if d <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t := time.NewTimer(d)
|
||||||
|
defer t.Stop()
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
case <-k.stop:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dits returns the element (character-speed) dit and the spacing (Farnsworth)
|
||||||
|
// dit as durations, read live so a speed change mid-message takes effect.
|
||||||
|
func (k *serialKeyer) dits() (elem, space time.Duration) {
|
||||||
|
k.mu.Lock()
|
||||||
|
w, f := k.wpm, k.farns
|
||||||
|
k.mu.Unlock()
|
||||||
|
if w < 5 {
|
||||||
|
w = 5
|
||||||
|
}
|
||||||
|
if w > 99 {
|
||||||
|
w = 99
|
||||||
|
}
|
||||||
|
elem = time.Duration(float64(time.Millisecond) * 1200.0 / float64(w))
|
||||||
|
space = elem
|
||||||
|
if f > 0 && f < w {
|
||||||
|
space = time.Duration(float64(time.Millisecond) * 1200.0 / float64(f))
|
||||||
|
}
|
||||||
|
return elem, space
|
||||||
|
}
|
||||||
@@ -49,6 +49,16 @@ type Config struct {
|
|||||||
AutoSpace bool `json:"autospace"` // auto letter-space
|
AutoSpace bool `json:"autospace"` // auto letter-space
|
||||||
UsePTT bool `json:"use_ptt"` // key PTT (Key/PTT output)
|
UsePTT bool `json:"use_ptt"` // key PTT (Key/PTT output)
|
||||||
SerialEcho bool `json:"serial_echo"` // device echoes sent chars back to host
|
SerialEcho bool `json:"serial_echo"` // device echoes sent chars back to host
|
||||||
|
|
||||||
|
// Type selects the keyer engine on this serial port:
|
||||||
|
// "" / "k1el" → a K1EL WinKeyer chip (the default, everything above applies)
|
||||||
|
// "serial" → the PC bit-bangs Morse on a control line (no WinKeyer chip):
|
||||||
|
// the "hardware CW keying" a Yaesu SCU-17 / generic interface
|
||||||
|
// uses. WPM / Weight / Farnsworth / LeadIn / Tail / UsePTT still
|
||||||
|
// apply; the paddle/sidetone/ratio fields are WinKeyer-only.
|
||||||
|
Type string `json:"type"`
|
||||||
|
CWKeyLine string `json:"cw_key_line"` // serial: "dtr" (CW on DTR, PTT on RTS — default) | "rts"
|
||||||
|
CWInvert bool `json:"cw_invert"` // serial: invert line polarity (active-LOW) for both key and PTT
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Config) normalised() Config {
|
func (c Config) normalised() Config {
|
||||||
@@ -93,6 +103,7 @@ type Manager struct {
|
|||||||
status Status
|
status Status
|
||||||
stopRead chan struct{}
|
stopRead chan struct{}
|
||||||
doneRead chan struct{}
|
doneRead chan struct{}
|
||||||
|
serial *serialKeyer // non-nil when cfg.Type == "serial" (DTR/RTS line keying)
|
||||||
|
|
||||||
onStatus func(Status)
|
onStatus func(Status)
|
||||||
onEcho func(string) // chars the device echoes back as it keys them
|
onEcho func(string) // chars the device echoes back as it keys them
|
||||||
@@ -130,6 +141,9 @@ func (m *Manager) Connect(cfg Config) error {
|
|||||||
if strings.TrimSpace(cfg.Port) == "" {
|
if strings.TrimSpace(cfg.Port) == "" {
|
||||||
return fmt.Errorf("winkeyer: no serial port selected")
|
return fmt.Errorf("winkeyer: no serial port selected")
|
||||||
}
|
}
|
||||||
|
if cfg.Type == "serial" {
|
||||||
|
return m.connectSerial(cfg)
|
||||||
|
}
|
||||||
m.Disconnect() // drop any existing link first
|
m.Disconnect() // drop any existing link first
|
||||||
|
|
||||||
p, err := serial.Open(cfg.Port, &serial.Mode{
|
p, err := serial.Open(cfg.Port, &serial.Mode{
|
||||||
@@ -175,6 +189,59 @@ func (m *Manager) Connect(cfg Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ApplySerialConfig live-updates a running serial-line keyer's control options
|
||||||
|
// (PTT keying, key line, invert, lead/tail, speed) WITHOUT reopening the port —
|
||||||
|
// so ticking "Key PTT line" (or changing the key line) takes effect from the next
|
||||||
|
// character, no reconnect needed. No-op unless a serial keyer is running.
|
||||||
|
func (m *Manager) ApplySerialConfig(cfg Config) {
|
||||||
|
cfg = cfg.normalised()
|
||||||
|
m.mu.Lock()
|
||||||
|
sk := m.serial
|
||||||
|
m.cfg = cfg
|
||||||
|
m.mu.Unlock()
|
||||||
|
if sk != nil {
|
||||||
|
sk.ApplyConfig(cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectSerial opens the port for line-keying (DTR=CW / RTS=PTT) — no K1EL
|
||||||
|
// handshake, no read loop. The PC bit-bangs the Morse itself (see serialcw.go).
|
||||||
|
func (m *Manager) connectSerial(cfg Config) error {
|
||||||
|
m.Disconnect() // drop any existing link first
|
||||||
|
|
||||||
|
// Open for line control only (DTR/RTS). On Windows this uses EscapeCommFunction
|
||||||
|
// so a held RTS (PTT) survives DTR (CW) toggling on USB adapters — see linectl_*.
|
||||||
|
line, err := openLineCtl(cfg.Port)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("winkeyer: open %s: %w", cfg.Port, err)
|
||||||
|
}
|
||||||
|
// The keyer updates busy state as it keys; mirror it into status + notify.
|
||||||
|
sk := newSerialKeyer(line, cfg, func(busy bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
changed := m.status.Busy != busy
|
||||||
|
m.status.Busy = busy
|
||||||
|
m.mu.Unlock()
|
||||||
|
if changed {
|
||||||
|
m.emit()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.port = nil // serial keyer owns its own (line-only) port, not m.port
|
||||||
|
m.serial = sk
|
||||||
|
m.cfg = cfg
|
||||||
|
m.status = Status{Connected: true, WPM: cfg.WPM, Port: cfg.Port}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
lineDesc := "DTR (CW) / RTS (PTT)"
|
||||||
|
if strings.EqualFold(cfg.CWKeyLine, "rts") {
|
||||||
|
lineDesc = "RTS (CW) / DTR (PTT)"
|
||||||
|
}
|
||||||
|
applog.Printf("winkeyer: serial CW keyer on %s — %s, PTT=%v (EscapeCommFunction line ctl)", cfg.Port, lineDesc, cfg.UsePTT)
|
||||||
|
m.emit()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// applyConfig pushes the keying parameters to the device.
|
// applyConfig pushes the keying parameters to the device.
|
||||||
func (m *Manager) applyConfig(c Config) error {
|
func (m *Manager) applyConfig(c Config) error {
|
||||||
cmds := [][]byte{
|
cmds := [][]byte{
|
||||||
@@ -256,7 +323,12 @@ func (m *Manager) SetSpeed(wpm int) error {
|
|||||||
if wpm > 99 {
|
if wpm > 99 {
|
||||||
wpm = 99
|
wpm = 99
|
||||||
}
|
}
|
||||||
if err := m.write([]byte{0x02, byte(wpm)}); err != nil {
|
m.mu.Lock()
|
||||||
|
sk := m.serial
|
||||||
|
m.mu.Unlock()
|
||||||
|
if sk != nil {
|
||||||
|
sk.SetSpeed(wpm)
|
||||||
|
} else if err := m.write([]byte{0x02, byte(wpm)}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -283,18 +355,39 @@ func (m *Manager) Send(text string) error {
|
|||||||
if out == "" {
|
if out == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
sk := m.serial
|
||||||
|
m.mu.Unlock()
|
||||||
|
if sk != nil {
|
||||||
|
sk.Send(out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return m.write([]byte(out))
|
return m.write([]byte(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop aborts the current message and clears the keyer buffer (command 0x0A).
|
// Stop aborts the current message and clears the keyer buffer (command 0x0A).
|
||||||
func (m *Manager) Stop() error {
|
func (m *Manager) Stop() error {
|
||||||
|
m.mu.Lock()
|
||||||
|
sk := m.serial
|
||||||
|
m.mu.Unlock()
|
||||||
|
if sk != nil {
|
||||||
|
sk.Stop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return m.write([]byte{0x0A})
|
return m.write([]byte{0x0A})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backspace removes the most recent character from the keyer's send buffer,
|
// Backspace removes the most recent character from the keyer's send buffer,
|
||||||
// IF it hasn't been keyed yet (command 0x08). Used by "send on typing" mode
|
// IF it hasn't been keyed yet (command 0x08). Used by "send on typing" mode
|
||||||
// so a fast typo can be corrected before it goes on the air.
|
// so a fast typo can be corrected before it goes on the air. The serial line
|
||||||
|
// keyer has no buffer to un-key from, so backspace is a no-op there.
|
||||||
func (m *Manager) Backspace() error {
|
func (m *Manager) Backspace() error {
|
||||||
|
m.mu.Lock()
|
||||||
|
sk := m.serial
|
||||||
|
m.mu.Unlock()
|
||||||
|
if sk != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return m.write([]byte{0x08})
|
return m.write([]byte{0x08})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,14 +406,29 @@ func (m *Manager) write(b []byte) error {
|
|||||||
func (m *Manager) Disconnect() {
|
func (m *Manager) Disconnect() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
p := m.port
|
p := m.port
|
||||||
|
sk := m.serial
|
||||||
stop, done := m.stopRead, m.doneRead
|
stop, done := m.stopRead, m.doneRead
|
||||||
m.port = nil
|
m.port = nil
|
||||||
|
m.serial = nil
|
||||||
m.stopRead = nil
|
m.stopRead = nil
|
||||||
m.doneRead = nil
|
m.doneRead = nil
|
||||||
connected := m.status.Connected
|
connected := m.status.Connected
|
||||||
m.status = Status{Connected: false}
|
m.status = Status{Connected: false}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if sk != nil {
|
||||||
|
// Serial line keyer: stop the worker (drops the key/PTT lines) then close.
|
||||||
|
sk.Close()
|
||||||
|
if p != nil {
|
||||||
|
_ = p.Close()
|
||||||
|
}
|
||||||
|
if connected {
|
||||||
|
applog.Printf("winkeyer: serial CW keyer disconnected")
|
||||||
|
m.emit()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if p != nil {
|
if p != nil {
|
||||||
_, _ = p.Write([]byte{0x00, 0x03}) // Host Close
|
_, _ = p.Write([]byte{0x00, 0x03}) // Host Close
|
||||||
_ = p.Close()
|
_ = p.Close()
|
||||||
|
|||||||
+14
-2
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.20.9"
|
appVersion = "0.21.0"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
@@ -85,14 +85,26 @@ func (a *App) sendTelemetryHeartbeat() {
|
|||||||
return // already counted today
|
return // already counted today
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// distinct_id identifies the USER. Prefer the station callsign — it's stable
|
||||||
|
// across every machine/reinstall the same operator runs, so one op counts as
|
||||||
|
// one user (a callsign is public in amateur radio). The random per-install ID
|
||||||
|
// is only a fallback until a callsign is configured; without it the same op on
|
||||||
|
// a laptop + desktop + Maestro showed up as three "users". install_id rides
|
||||||
|
// along as a property so multiple machines under one callsign stay visible.
|
||||||
|
installID := a.telemetryInstallID()
|
||||||
|
distinctID := installID
|
||||||
|
if call := a.activeCallsign(); call != "" {
|
||||||
|
distinctID = call
|
||||||
|
}
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"api_key": posthogAPIKey,
|
"api_key": posthogAPIKey,
|
||||||
"event": "app_opened",
|
"event": "app_opened",
|
||||||
"distinct_id": a.telemetryInstallID(),
|
"distinct_id": distinctID,
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"version": appVersion,
|
"version": appVersion,
|
||||||
"os": runtime.GOOS,
|
"os": runtime.GOOS,
|
||||||
"arch": runtime.GOARCH,
|
"arch": runtime.GOARCH,
|
||||||
|
"install_id": installID,
|
||||||
"$lib": "opslog",
|
"$lib": "opslog",
|
||||||
},
|
},
|
||||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
|||||||
@@ -23,6 +23,17 @@ Plus a **leading/trailing** strip, a **prefix** prepended to found references, a
|
|||||||
**dynamic** mode (any value counts, like POTA), and the **fallback searches**
|
**dynamic** mode (any value counts, like POTA), and the **fallback searches**
|
||||||
described below.
|
described below.
|
||||||
|
|
||||||
|
**One QSO can count for several references.** A contact always yields *every*
|
||||||
|
reference its field holds — the value is split on commas and semicolons, so an
|
||||||
|
n-fer POTA activation logged as `US-6544,US-0680` credits both parks, and a
|
||||||
|
grid-line VUCC contact credits each grid. There is no "one reference per QSO"
|
||||||
|
switch — it was never useful.
|
||||||
|
|
||||||
|
**Match by `code`** looks up each token of the field in the reference list (so a
|
||||||
|
partial or multi-value field still resolves), while **`exact match`** requires
|
||||||
|
the whole field to equal the reference. Use exact match when a substring would
|
||||||
|
cause false hits.
|
||||||
|
|
||||||
### The five rules to remember
|
### The five rules to remember
|
||||||
|
|
||||||
1. **Every regex is case-insensitive.** Award and per-reference patterns match
|
1. **Every regex is case-insensitive.** Award and per-reference patterns match
|
||||||
@@ -72,6 +83,42 @@ A custom province award where the log rarely spells the province out (it names a
|
|||||||
Now a QSO is resolved by the province **name** if present, else by a **city**
|
Now a QSO is resolved by the province **name** if present, else by a **city**
|
||||||
regex — first in the QTH, then in the address. First hit wins.
|
regex — first in the QTH, then in the address. First hit wins.
|
||||||
|
|
||||||
|
## Why doesn't a QSO count? — Test & Missing refs
|
||||||
|
|
||||||
|
An award that matches nothing is the hardest thing to debug: the column is just
|
||||||
|
empty, and you can't tell whether the QSO was out of scope, the field was empty,
|
||||||
|
the rule looked in the wrong place, or the reference isn't on the list. Two tools
|
||||||
|
answer that.
|
||||||
|
|
||||||
|
### Test a callsign (award editor)
|
||||||
|
|
||||||
|
In the **award editor** there's a **Test** box: type a callsign and OpsLog
|
||||||
|
replays that award's rules against every QSO you have with that station and shows
|
||||||
|
what happened, step by step — the exact same code path the real matcher uses, so
|
||||||
|
what you see is what actually runs. For each contact it reports:
|
||||||
|
|
||||||
|
- whether the QSO is **in scope** (and, if not, *why* — wrong DXCC, band, mode,
|
||||||
|
emission, or outside the valid-from/to dates);
|
||||||
|
- each rule in turn (**primary**, then **Fallback 1, 2, …**), the **field value**
|
||||||
|
it scanned, the **candidates** it produced, which ones were **kept**, and which
|
||||||
|
were **rejected** (with the reason — e.g. "not on the reference list");
|
||||||
|
- rules that were **skipped** because an earlier one already matched;
|
||||||
|
- any **manual** reference you assigned by hand;
|
||||||
|
- the final **result** — what the QSO counts for.
|
||||||
|
|
||||||
|
This is the fastest way to fix a rule: if the primary scanned the wrong field, or
|
||||||
|
a candidate was rejected as unlisted, the trace says so directly.
|
||||||
|
|
||||||
|
### Missing refs (awards panel)
|
||||||
|
|
||||||
|
For an award scoped to a DXCC entity (DDFM, WAS, RAC, WAJA…), the **Missing refs**
|
||||||
|
view lists contacts that ARE in scope (right DXCC / band / mode / dates) **but
|
||||||
|
where no reference was found** — so they don't count yet. Sort by a column, tick
|
||||||
|
the matching contacts, and **assign the reference** to all of them at once (or
|
||||||
|
click a row to open the QSO and fix the field). Recompute and the fixed contacts
|
||||||
|
drop off the list. This is how you close the gaps a matcher can't fill on its own
|
||||||
|
(a French QSO logged without its department, say).
|
||||||
|
|
||||||
## Live detection & manual refs
|
## Live detection & manual refs
|
||||||
|
|
||||||
- References are detected **live** as you enter a callsign.
|
- References are detected **live** as you enter a callsign.
|
||||||
@@ -89,6 +136,15 @@ regex — first in the QTH, then in the address. First hit wins.
|
|||||||
or **both**. Award columns are opt-in per the Columns picker
|
or **both**. Award columns are opt-in per the Columns picker
|
||||||
([[Recent QSOs and Filters]]).
|
([[Recent QSOs and Filters]]).
|
||||||
|
|
||||||
|
## Built-in updates vs your edits
|
||||||
|
|
||||||
|
Built-in awards ship with the app, definition **and** reference list. When a
|
||||||
|
newer version fixes a shipped award (a better fallback chain, a corrected
|
||||||
|
reference list), OpsLog offers to apply that update — **unless you've edited that
|
||||||
|
award yourself**, in which case your version is left alone (your work outranks
|
||||||
|
ours). Awards you created are never touched by updates. So you can freely tune a
|
||||||
|
built-in award without fear of a future release overwriting it.
|
||||||
|
|
||||||
## Rescan
|
## Rescan
|
||||||
|
|
||||||
**Rescan** re-pulls the logbook and recomputes — it picks up fresh LoTW / QRZ /
|
**Rescan** re-pulls the logbook and recomputes — it picks up fresh LoTW / QRZ /
|
||||||
|
|||||||
Reference in New Issue
Block a user