Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6ca7a8bdd | ||
|
|
9be3f9147b | ||
|
|
f7f801cdb7 | ||
|
|
43f15f1a2c | ||
|
|
2c5416500f | ||
|
|
fcdc5809e6 | ||
|
|
7254950162 | ||
|
|
1668455ff4 | ||
|
|
c07b746d4b | ||
|
|
d128dabc19 | ||
|
|
2823f3e401 | ||
|
|
1070637c40 | ||
|
|
13de53772b | ||
|
|
6b04072e52 | ||
|
|
64b8ba7fdc | ||
|
|
a9ca50b2dd | ||
|
|
512b5fd35b | ||
|
|
77e3d011a0 | ||
|
|
b7dd8d4852 | ||
|
|
4c75680689 | ||
|
|
5aac28f564 | ||
|
|
c91c8c3b47 | ||
|
|
968da5488c | ||
|
|
1b5b0c2e90 | ||
|
|
0385aed760 |
@@ -46,3 +46,4 @@ cat.log
|
|||||||
.env.local
|
.env.local
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
*.syso
|
||||||
|
|||||||
+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
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import (
|
|||||||
"hamlog/internal/qslcard"
|
"hamlog/internal/qslcard"
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
"hamlog/internal/relaydev"
|
"hamlog/internal/relaydev"
|
||||||
|
"hamlog/internal/rotator/gs232"
|
||||||
"hamlog/internal/rotator/pst"
|
"hamlog/internal/rotator/pst"
|
||||||
"hamlog/internal/rotgenius"
|
"hamlog/internal/rotgenius"
|
||||||
"hamlog/internal/settings"
|
"hamlog/internal/settings"
|
||||||
@@ -158,8 +159,10 @@ const (
|
|||||||
keyRotatorHost = "rotator.host"
|
keyRotatorHost = "rotator.host"
|
||||||
keyRotatorPort = "rotator.port"
|
keyRotatorPort = "rotator.port"
|
||||||
keyRotatorHasElevation = "rotator.has_elevation"
|
keyRotatorHasElevation = "rotator.has_elevation"
|
||||||
keyRotatorType = "rotator.type" // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP)
|
keyRotatorType = "rotator.type" // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (microHAM ARCO, GS-232A)
|
||||||
keyRotatorNum = "rotator.num" // Rotator Genius rotator index: 1 or 2
|
keyRotatorNum = "rotator.num" // Rotator Genius rotator index: 1 or 2
|
||||||
|
keyRotatorTransport = "rotator.transport" // arco: "tcp" (LAN) | "serial" (USB virtual COM)
|
||||||
|
keyRotatorComPort = "rotator.com_port" // arco serial transport
|
||||||
|
|
||||||
// Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
|
// Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
|
||||||
// "ultrabeam." prefix for backward compatibility with configs saved before the
|
// "ultrabeam." prefix for backward compatibility with configs saved before the
|
||||||
@@ -211,9 +214,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
|
||||||
|
|
||||||
@@ -466,8 +471,10 @@ type App struct {
|
|||||||
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
|
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
|
||||||
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
|
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
|
||||||
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
||||||
spe *spe.Client // SPE Expert amplifier (serial/TCP); nil when disabled or not SPE
|
spe *spe.Client // legacy pointer: FIRST enabled SPE amp (kept for the pre-multi bindings)
|
||||||
acom *acom.Client // ACOM 500S/600S/700S/1200S/2020S amplifier (serial/TCP); nil when disabled or not ACOM
|
acom *acom.Client // legacy pointer: FIRST enabled ACOM amp
|
||||||
|
ampsMu sync.Mutex // guards ampInsts
|
||||||
|
ampInsts map[string]*ampInst // one running client per enabled configured amplifier, by config ID
|
||||||
audioMgr *audio.Manager
|
audioMgr *audio.Manager
|
||||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||||
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
|
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
|
||||||
@@ -527,6 +534,13 @@ type App struct {
|
|||||||
// must be allowed through.
|
// must be allowed through.
|
||||||
shuttingDown bool
|
shuttingDown bool
|
||||||
compact bool // window is in the compact one-row mode (skip saving its tiny size as the normal geometry)
|
compact bool // window is in the compact one-row mode (skip saving its tiny size as the normal geometry)
|
||||||
|
// Geometry captured when ENTERING compact mode, restored on the way back —
|
||||||
|
// otherwise leaving compact snapped the window to a hardcoded 1400×900 at
|
||||||
|
// wherever the compact strip had been dragged.
|
||||||
|
preCompactW, preCompactH int
|
||||||
|
preCompactX, preCompactY int
|
||||||
|
preCompactMax bool
|
||||||
|
preCompactValid bool
|
||||||
|
|
||||||
// Cached operator location used to compute distance/bearing for
|
// Cached operator location used to compute distance/bearing for
|
||||||
// cluster spots. Refreshed on profile activation; zero means
|
// cluster spots. Refreshed on profile activation; zero means
|
||||||
@@ -1063,7 +1077,7 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// Antenna Genius switch: connect in the background if enabled.
|
// Antenna Genius switch: connect in the background if enabled.
|
||||||
a.startAntGenius()
|
a.startAntGenius()
|
||||||
// PowerGenius XL amp fan control: connect in the background if enabled.
|
// PowerGenius XL amp fan control: connect in the background if enabled.
|
||||||
a.startPGXL()
|
a.startAmps()
|
||||||
|
|
||||||
// Autostart: launch the active profile's configured external programs that
|
// Autostart: launch the active profile's configured external programs that
|
||||||
// aren't already running (WSJT-X, JTAlert, rotator control, …). Background
|
// aren't already running (WSJT-X, JTAlert, rotator control, …). Background
|
||||||
@@ -1808,6 +1822,17 @@ func (a *App) ResetDatabaseToDefault() error {
|
|||||||
// widths, sort…) in the DB settings table under a "ui." namespace, so they
|
// widths, sort…) in the DB settings table under a "ui." namespace, so they
|
||||||
// travel with the logbook and survive a reinstall — unlike the WebView's
|
// travel with the logbook and survive a reinstall — unlike the WebView's
|
||||||
// localStorage. Values are opaque JSON blobs owned by the frontend.
|
// localStorage. Values are opaque JSON blobs owned by the frontend.
|
||||||
|
// groupDigitalSlots reports the "digital modes count as one" preference
|
||||||
|
// (Settings → General; stored as a portable ui.* pref so the frontend matrix
|
||||||
|
// and this backend cluster logic read the same switch).
|
||||||
|
func (a *App) groupDigitalSlots() bool {
|
||||||
|
if a.settings == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, _ := a.settings.Get(a.ctx, "ui.opslog.groupDigitalSlots")
|
||||||
|
return v == "1"
|
||||||
|
}
|
||||||
|
|
||||||
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
|
return "", nil
|
||||||
@@ -1960,6 +1985,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)
|
||||||
@@ -1971,7 +2002,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)
|
||||||
}
|
}
|
||||||
@@ -5143,6 +5173,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)
|
||||||
@@ -5161,6 +5196,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.
|
||||||
@@ -5201,6 +5264,19 @@ func (a *App) SetCompactMode(on bool) {
|
|||||||
if a.ctx == nil {
|
if a.ctx == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if on && !a.compact {
|
||||||
|
// Capture the normal geometry BEFORE shrinking, so leaving compact puts
|
||||||
|
// the window back exactly where and how big it was.
|
||||||
|
a.preCompactMax = wruntime.WindowIsMaximised(a.ctx)
|
||||||
|
if a.preCompactMax {
|
||||||
|
// Unmaximise first: the size/position of a maximised window are the
|
||||||
|
// monitor's, not the user's chosen geometry.
|
||||||
|
wruntime.WindowUnmaximise(a.ctx)
|
||||||
|
}
|
||||||
|
a.preCompactW, a.preCompactH = wruntime.WindowGetSize(a.ctx)
|
||||||
|
a.preCompactX, a.preCompactY = wruntime.WindowGetPosition(a.ctx)
|
||||||
|
a.preCompactValid = a.preCompactW >= normalMinW && a.preCompactH >= normalMinH
|
||||||
|
}
|
||||||
a.compact = on
|
a.compact = on
|
||||||
if on {
|
if on {
|
||||||
// Lock the window to the compact size by pinning min == max. Without
|
// Lock the window to the compact size by pinning min == max. Without
|
||||||
@@ -5215,7 +5291,16 @@ func (a *App) SetCompactMode(on bool) {
|
|||||||
// Release the lock first (raise the max) before growing back.
|
// Release the lock first (raise the max) before growing back.
|
||||||
wruntime.WindowSetMaxSize(a.ctx, maxW, maxH)
|
wruntime.WindowSetMaxSize(a.ctx, maxW, maxH)
|
||||||
wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH)
|
wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH)
|
||||||
wruntime.WindowSetSize(a.ctx, normalW, normalH)
|
if a.preCompactValid {
|
||||||
|
wruntime.WindowSetSize(a.ctx, a.preCompactW, a.preCompactH)
|
||||||
|
wruntime.WindowSetPosition(a.ctx, a.preCompactX, a.preCompactY)
|
||||||
|
if a.preCompactMax {
|
||||||
|
wruntime.WindowMaximise(a.ctx)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Nothing captured (compact since launch) — fall back to the default.
|
||||||
|
wruntime.WindowSetSize(a.ctx, normalW, normalH)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6181,19 +6266,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
|
||||||
@@ -6219,6 +6299,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
|
||||||
@@ -6439,11 +6526,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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -7122,6 +7213,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.
|
||||||
@@ -8157,6 +8297,12 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
msg = err.Error()
|
msg = err.Error()
|
||||||
}
|
}
|
||||||
emit("LoTW upload failed: " + msg)
|
emit("LoTW upload failed: " + msg)
|
||||||
|
// The qslmgr:log console is only visible in the QSL Manager — a failure
|
||||||
|
// triggered from the Recent QSOs right-click was completely silent, which
|
||||||
|
// read as "send to LoTW does nothing". Surface it as a toast too.
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "toast", "LoTW upload failed: "+msg)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
a.markExtUploaded(svc, id, "")
|
a.markExtUploaded(svc, id, "")
|
||||||
@@ -8311,6 +8457,16 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
}
|
}
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": uploaded, "total": len(ids)})
|
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": uploaded, "total": len(ids)})
|
||||||
|
// Always end with a visible summary — the right-click "Send to …" runs in
|
||||||
|
// the background and the per-QSO log lives in the QSL Manager console.
|
||||||
|
label := map[extsvc.Service]string{
|
||||||
|
extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog",
|
||||||
|
extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL",
|
||||||
|
}[svc]
|
||||||
|
if label == "" {
|
||||||
|
label = string(svc)
|
||||||
|
}
|
||||||
|
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("%s: %d/%d QSO uploaded", label, uploaded, len(ids)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10836,6 +10992,86 @@ func (a *App) FlexSetWNBLevel(l int) error {
|
|||||||
}
|
}
|
||||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetWNBLevel(l) })
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetWNBLevel(l) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── SmartSDR v4 DSP (8000/Aurora series): NRL / ANFL / NRS / RNN / ANFT / NRF ──
|
||||||
|
|
||||||
|
func (a *App) FlexSetLMSNR(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSNR(on) })
|
||||||
|
}
|
||||||
|
func (a *App) FlexSetLMSNRLevel(l int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSNRLevel(l) })
|
||||||
|
}
|
||||||
|
func (a *App) FlexSetLMSANF(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSANF(on) })
|
||||||
|
}
|
||||||
|
func (a *App) FlexSetLMSANFLevel(l int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSANFLevel(l) })
|
||||||
|
}
|
||||||
|
func (a *App) FlexSetSpeexNR(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSpeexNR(on) })
|
||||||
|
}
|
||||||
|
func (a *App) FlexSetSpeexNRLevel(l int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSpeexNRLevel(l) })
|
||||||
|
}
|
||||||
|
func (a *App) FlexSetRNN(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRNN(on) })
|
||||||
|
}
|
||||||
|
func (a *App) FlexSetANFT(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetANFT(on) })
|
||||||
|
}
|
||||||
|
func (a *App) FlexSetNRF(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNRF(on) })
|
||||||
|
}
|
||||||
|
func (a *App) FlexSetNRFLevel(l int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNRFLevel(l) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexSetDAX routes the active slice's audio to a DAX channel (0 = off, 1-8).
|
||||||
|
func (a *App) FlexSetDAX(ch int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetDAX(ch) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexSetTXDAX toggles DAX as the transmit audio source — SmartSDR's
|
||||||
|
// transmit-bar DAX button (what WSJT-X TX audio goes through).
|
||||||
|
func (a *App) FlexSetTXDAX(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXDAX(on) })
|
||||||
|
}
|
||||||
func (a *App) FlexSetTXFilter(low, high int) error {
|
func (a *App) FlexSetTXFilter(low, high int) error {
|
||||||
if a.cat == nil {
|
if a.cat == nil {
|
||||||
return fmt.Errorf("cat not initialized")
|
return fmt.Errorf("cat not initialized")
|
||||||
@@ -11214,7 +11450,14 @@ func (a *App) ActivateProfile(id int64) error {
|
|||||||
// target (local SQLite or its own MySQL) so QSOs go to the right logbook.
|
// target (local SQLite or its own MySQL) so QSOs go to the right logbook.
|
||||||
if p, err := a.profiles.Get(a.ctx, id); err == nil {
|
if p, err := a.profiles.Get(a.ctx, id); err == nil {
|
||||||
if err := a.switchLogbook(p); err != nil {
|
if err := a.switchLogbook(p); err != nil {
|
||||||
|
// The switch failed (typically: this profile's MySQL is unreachable right
|
||||||
|
// now) and the PREVIOUS logbook stays live. Silently staying on the old
|
||||||
|
// database is how QSOs end up in the wrong log — tell the operator.
|
||||||
applog.Printf("activate profile %d: logbook switch failed: %v", id, err)
|
applog.Printf("activate profile %d: logbook switch failed: %v", id, err)
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf(
|
||||||
|
"Profil %q : connexion à sa base impossible — l'ancienne base reste active ! (%v)", p.Name, err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Re-apply every settings-dependent subsystem for the new profile.
|
// Re-apply every settings-dependent subsystem for the new profile.
|
||||||
@@ -11276,11 +11519,13 @@ func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error
|
|||||||
// RotatorSettings is the JSON shape for the Hardware → Rotator panel.
|
// RotatorSettings is the JSON shape for the Hardware → Rotator panel.
|
||||||
type RotatorSettings struct {
|
type RotatorSettings struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP)
|
Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (microHAM ARCO, Yaesu GS-232A over TCP)
|
||||||
Host string `json:"host"` // default 127.0.0.1
|
Host string `json:"host"` // default 127.0.0.1
|
||||||
Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius)
|
Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius) / 4001 (arco — must match the port set in ARCO's LAN CONTROL PROTOCOL)
|
||||||
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator)
|
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator)
|
||||||
RotatorNum int `json:"rotator_num"` // Rotator Genius index: 1 or 2
|
RotatorNum int `json:"rotator_num"` // Rotator Genius index: 1 or 2
|
||||||
|
Transport string `json:"transport"` // arco: "tcp" (LAN) | "serial" (USB virtual COM)
|
||||||
|
ComPort string `json:"com_port"` // arco serial transport
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRotatorSettings returns the persisted rotator config with defaults.
|
// GetRotatorSettings returns the persisted rotator config with defaults.
|
||||||
@@ -11290,12 +11535,13 @@ func (a *App) GetRotatorSettings() (RotatorSettings, error) {
|
|||||||
return out, fmt.Errorf("db not initialized")
|
return out, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx,
|
m, err := a.settings.GetMany(a.ctx,
|
||||||
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum)
|
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum,
|
||||||
|
keyRotatorTransport, keyRotatorComPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
out.Enabled = m[keyRotatorEnabled] == "1"
|
out.Enabled = m[keyRotatorEnabled] == "1"
|
||||||
if t := m[keyRotatorType]; t == "rotgenius" || t == "pst" {
|
if t := m[keyRotatorType]; t == "rotgenius" || t == "pst" || t == "arco" {
|
||||||
out.Type = t
|
out.Type = t
|
||||||
}
|
}
|
||||||
if h := m[keyRotatorHost]; h != "" {
|
if h := m[keyRotatorHost]; h != "" {
|
||||||
@@ -11305,11 +11551,18 @@ func (a *App) GetRotatorSettings() (RotatorSettings, error) {
|
|||||||
out.Port = p
|
out.Port = p
|
||||||
} else if out.Type == "rotgenius" {
|
} else if out.Type == "rotgenius" {
|
||||||
out.Port = 9006 // native default when no port stored yet
|
out.Port = 9006 // native default when no port stored yet
|
||||||
|
} else if out.Type == "arco" {
|
||||||
|
out.Port = 4001 // placeholder — the real number is set in ARCO's LAN menu
|
||||||
}
|
}
|
||||||
out.HasElevation = m[keyRotatorHasElevation] == "1"
|
out.HasElevation = m[keyRotatorHasElevation] == "1"
|
||||||
if rn, _ := strconv.Atoi(m[keyRotatorNum]); rn == 2 {
|
if rn, _ := strconv.Atoi(m[keyRotatorNum]); rn == 2 {
|
||||||
out.RotatorNum = 2
|
out.RotatorNum = 2
|
||||||
}
|
}
|
||||||
|
out.Transport = "tcp"
|
||||||
|
if m[keyRotatorTransport] == "serial" {
|
||||||
|
out.Transport = "serial"
|
||||||
|
}
|
||||||
|
out.ComPort = m[keyRotatorComPort]
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11319,18 +11572,21 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
if s.Type != "rotgenius" {
|
if s.Type != "rotgenius" && s.Type != "arco" {
|
||||||
s.Type = "pst"
|
s.Type = "pst"
|
||||||
}
|
}
|
||||||
if s.Host == "" {
|
if s.Host == "" {
|
||||||
s.Host = "127.0.0.1"
|
s.Host = "127.0.0.1"
|
||||||
}
|
}
|
||||||
if s.Port <= 0 || s.Port > 65535 {
|
if s.Port <= 0 || s.Port > 65535 {
|
||||||
s.Port = map[string]int{"rotgenius": 9006, "pst": 12000}[s.Type]
|
s.Port = map[string]int{"rotgenius": 9006, "pst": 12000, "arco": 4001}[s.Type]
|
||||||
}
|
}
|
||||||
if s.RotatorNum != 2 {
|
if s.RotatorNum != 2 {
|
||||||
s.RotatorNum = 1
|
s.RotatorNum = 1
|
||||||
}
|
}
|
||||||
|
if s.Transport != "serial" {
|
||||||
|
s.Transport = "tcp"
|
||||||
|
}
|
||||||
for k, v := range map[string]string{
|
for k, v := range map[string]string{
|
||||||
keyRotatorEnabled: boolStr(s.Enabled),
|
keyRotatorEnabled: boolStr(s.Enabled),
|
||||||
keyRotatorType: s.Type,
|
keyRotatorType: s.Type,
|
||||||
@@ -11338,6 +11594,8 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
|
|||||||
keyRotatorPort: strconv.Itoa(s.Port),
|
keyRotatorPort: strconv.Itoa(s.Port),
|
||||||
keyRotatorHasElevation: boolStr(s.HasElevation),
|
keyRotatorHasElevation: boolStr(s.HasElevation),
|
||||||
keyRotatorNum: strconv.Itoa(s.RotatorNum),
|
keyRotatorNum: strconv.Itoa(s.RotatorNum),
|
||||||
|
keyRotatorTransport: s.Transport,
|
||||||
|
keyRotatorComPort: strings.TrimSpace(s.ComPort),
|
||||||
} {
|
} {
|
||||||
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
|
||||||
@@ -11346,6 +11604,15 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// arcoClient builds the GS-232 client for the configured ARCO transport:
|
||||||
|
// USB virtual COM (serial) or the LAN CONTROL PROTOCOL TCP port.
|
||||||
|
func arcoClient(s RotatorSettings) *gs232.Client {
|
||||||
|
if s.Transport == "serial" {
|
||||||
|
return gs232.NewSerial(s.ComPort)
|
||||||
|
}
|
||||||
|
return gs232.New(s.Host, s.Port)
|
||||||
|
}
|
||||||
|
|
||||||
// RotatorHeading is the live antenna heading for the status bar.
|
// RotatorHeading is the live antenna heading for the status bar.
|
||||||
type RotatorHeading struct {
|
type RotatorHeading struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
@@ -11371,6 +11638,13 @@ func (a *App) GetRotatorHeading() RotatorHeading {
|
|||||||
}
|
}
|
||||||
return RotatorHeading{Enabled: true, OK: true, Azimuth: st.Azimuth, Raw: raw}
|
return RotatorHeading{Enabled: true, OK: true, Azimuth: st.Azimuth, Raw: raw}
|
||||||
}
|
}
|
||||||
|
if s.Type == "arco" {
|
||||||
|
az, raw, herr := arcoClient(s).Heading()
|
||||||
|
if herr != nil {
|
||||||
|
return RotatorHeading{Enabled: true, OK: false, Raw: herr.Error()}
|
||||||
|
}
|
||||||
|
return RotatorHeading{Enabled: true, OK: true, Azimuth: az, Raw: raw}
|
||||||
|
}
|
||||||
az, raw, herr := pst.New(s.Host, s.Port).Heading()
|
az, raw, herr := pst.New(s.Host, s.Port).Heading()
|
||||||
if herr != nil {
|
if herr != nil {
|
||||||
return RotatorHeading{Enabled: true, OK: false, Raw: raw}
|
return RotatorHeading{Enabled: true, OK: false, Raw: raw}
|
||||||
@@ -11391,6 +11665,9 @@ func (a *App) RotatorGoTo(az int, el int) error {
|
|||||||
if s.Type == "rotgenius" {
|
if s.Type == "rotgenius" {
|
||||||
return rotgenius.New(s.Host, s.Port).GoTo(s.RotatorNum, az)
|
return rotgenius.New(s.Host, s.Port).GoTo(s.RotatorNum, az)
|
||||||
}
|
}
|
||||||
|
if s.Type == "arco" {
|
||||||
|
return arcoClient(s).GoTo(az)
|
||||||
|
}
|
||||||
return pst.New(s.Host, s.Port).GoTo(az, s.HasElevation, el)
|
return pst.New(s.Host, s.Port).GoTo(az, s.HasElevation, el)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11406,6 +11683,9 @@ func (a *App) RotatorStop() error {
|
|||||||
if s.Type == "rotgenius" {
|
if s.Type == "rotgenius" {
|
||||||
return rotgenius.New(s.Host, s.Port).Stop()
|
return rotgenius.New(s.Host, s.Port).Stop()
|
||||||
}
|
}
|
||||||
|
if s.Type == "arco" {
|
||||||
|
return arcoClient(s).Stop()
|
||||||
|
}
|
||||||
return pst.New(s.Host, s.Port).Stop()
|
return pst.New(s.Host, s.Port).Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11423,6 +11703,9 @@ func (a *App) RotatorPark() error {
|
|||||||
if s.Type == "rotgenius" {
|
if s.Type == "rotgenius" {
|
||||||
return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius")
|
return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius")
|
||||||
}
|
}
|
||||||
|
if s.Type == "arco" {
|
||||||
|
return fmt.Errorf("park is a PstRotator feature; not available over the ARCO GS-232 link")
|
||||||
|
}
|
||||||
return pst.New(s.Host, s.Port).Park()
|
return pst.New(s.Host, s.Port).Park()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11447,6 +11730,18 @@ func (a *App) TestRotator(s RotatorSettings) error {
|
|||||||
// reply, so a rejected command surfaces as an error instead of a false OK.
|
// reply, so a rejected command surfaces as an error instead of a false OK.
|
||||||
return rotgenius.New(s.Host, s.Port).GoTo(rn, 0)
|
return rotgenius.New(s.Host, s.Port).GoTo(rn, 0)
|
||||||
}
|
}
|
||||||
|
if s.Type == "arco" {
|
||||||
|
if s.Port <= 0 || s.Port > 65535 {
|
||||||
|
s.Port = 4001
|
||||||
|
}
|
||||||
|
if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" {
|
||||||
|
return fmt.Errorf("select the ARCO's COM port first")
|
||||||
|
}
|
||||||
|
// A heading query proves the link AND that the ARCO port really speaks
|
||||||
|
// GS-232 — without moving the antenna.
|
||||||
|
_, _, err := arcoClient(s).Heading()
|
||||||
|
return err
|
||||||
|
}
|
||||||
if s.Port <= 0 || s.Port > 65535 {
|
if s.Port <= 0 || s.Port > 65535 {
|
||||||
s.Port = 12000
|
s.Port = 12000
|
||||||
}
|
}
|
||||||
@@ -12341,53 +12636,288 @@ func (a *App) SavePGXLSettings(s PGXLSettings) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.startPGXL()
|
a.startAmps()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// startPGXL stops any existing client and starts a fresh one if enabled.
|
// ── Multiple amplifiers ────────────────────────────────────────────────
|
||||||
func (a *App) startPGXL() {
|
// Operators can run SEVERAL amps (even two SPEs combined for more power), so
|
||||||
if a.pgxl != nil {
|
// the config is a LIST. The legacy single-amp keyPGXL* settings migrate into
|
||||||
|
// entry #1 the first time the list is read. Each enabled entry gets its own
|
||||||
|
// client; the legacy a.pgxl/a.spe/a.acom fields point at the FIRST enabled amp
|
||||||
|
// of each family so the pre-multi bindings (and the Flex-panel merge) keep
|
||||||
|
// working unchanged.
|
||||||
|
|
||||||
|
const keyAmpsList = "amps.json"
|
||||||
|
|
||||||
|
// AmpConfig is one configured amplifier in Settings → Amplifier.
|
||||||
|
type AmpConfig struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"` // user label, e.g. "SPE left"
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Type string `json:"type"` // "pgxl" | "spe13"|"spe15"|"spe2k" | "acom500"…"acom2020"
|
||||||
|
Transport string `json:"transport"` // "tcp" | "serial"
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
ComPort string `json:"com_port"`
|
||||||
|
Baud int `json:"baud"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ampInst struct {
|
||||||
|
cfg AmpConfig
|
||||||
|
pgxl *powergenius.Client
|
||||||
|
spe *spe.Client
|
||||||
|
acom *acom.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *ampInst) stopAll() {
|
||||||
|
if i.pgxl != nil {
|
||||||
|
i.pgxl.Stop()
|
||||||
|
}
|
||||||
|
if i.spe != nil {
|
||||||
|
i.spe.Stop()
|
||||||
|
}
|
||||||
|
if i.acom != nil {
|
||||||
|
i.acom.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ampTypeLabel is the default display name for an amp type.
|
||||||
|
func ampTypeLabel(t string) string {
|
||||||
|
switch {
|
||||||
|
case t == "" || t == "pgxl":
|
||||||
|
return "PowerGenius XL"
|
||||||
|
case strings.HasPrefix(t, "spe"):
|
||||||
|
return "SPE " + map[string]string{"spe13": "1.3K-FA", "spe15": "1.5K-FA", "spe2k": "2K-FA"}[t]
|
||||||
|
case strings.HasPrefix(t, "acom"):
|
||||||
|
return "ACOM " + strings.TrimPrefix(t, "acom") + "S"
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAmplifiers returns the configured amplifier list. When none was ever
|
||||||
|
// saved, the legacy single-amp settings (if any) are presented as entry #1 —
|
||||||
|
// persisted on the next SaveAmplifiers.
|
||||||
|
func (a *App) GetAmplifiers() ([]AmpConfig, error) {
|
||||||
|
if a.settings == nil {
|
||||||
|
return nil, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
raw, _ := a.settings.Get(a.ctx, keyAmpsList)
|
||||||
|
if strings.TrimSpace(raw) != "" {
|
||||||
|
var list []AmpConfig
|
||||||
|
if err := json.Unmarshal([]byte(raw), &list); err == nil {
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s, err := a.GetPGXLSettings()
|
||||||
|
if err != nil || (!s.Enabled && strings.TrimSpace(s.Host) == "" && strings.TrimSpace(s.ComPort) == "") {
|
||||||
|
return []AmpConfig{}, nil // never configured
|
||||||
|
}
|
||||||
|
return []AmpConfig{{
|
||||||
|
ID: "amp-1", Name: ampTypeLabel(s.Type), Enabled: s.Enabled,
|
||||||
|
Type: s.Type, Transport: s.Transport, Host: s.Host, Port: s.Port, ComPort: s.ComPort, Baud: s.Baud,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveAmplifiers persists the amplifier list and (re)starts the clients.
|
||||||
|
func (a *App) SaveAmplifiers(list []AmpConfig) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
for i := range list {
|
||||||
|
c := &list[i]
|
||||||
|
if strings.TrimSpace(c.ID) == "" {
|
||||||
|
c.ID = fmt.Sprintf("amp-%d-%d", time.Now().Unix(), i)
|
||||||
|
}
|
||||||
|
if c.Type == "" {
|
||||||
|
c.Type = "pgxl"
|
||||||
|
}
|
||||||
|
if c.Type == "pgxl" || c.Transport != "serial" {
|
||||||
|
c.Transport = "tcp"
|
||||||
|
}
|
||||||
|
if c.Port <= 0 || c.Port > 65535 {
|
||||||
|
c.Port = 9008
|
||||||
|
}
|
||||||
|
if c.Baud <= 0 {
|
||||||
|
if strings.HasPrefix(c.Type, "acom") {
|
||||||
|
c.Baud = 9600
|
||||||
|
} else {
|
||||||
|
c.Baud = 115200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.Name) == "" {
|
||||||
|
c.Name = ampTypeLabel(c.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(list)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.settings.Set(a.ctx, keyAmpsList, string(b)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.startAmps()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startAmps stops every running amp client and starts one per enabled entry.
|
||||||
|
func (a *App) startAmps() {
|
||||||
|
a.ampsMu.Lock()
|
||||||
|
old := a.ampInsts
|
||||||
|
a.ampInsts = map[string]*ampInst{}
|
||||||
|
a.ampsMu.Unlock()
|
||||||
|
for _, inst := range old {
|
||||||
// Stop() can block up to the dial timeout waiting for an in-progress
|
// Stop() can block up to the dial timeout waiting for an in-progress
|
||||||
// connect; tear down in the background so saving Settings (this runs on
|
// connect; tear down in the background so saving Settings (this runs on
|
||||||
// the Wails RPC goroutine) doesn't freeze the UI.
|
// the Wails RPC goroutine) doesn't freeze the UI.
|
||||||
go a.pgxl.Stop()
|
go inst.stopAll()
|
||||||
a.pgxl = nil
|
|
||||||
}
|
}
|
||||||
if a.spe != nil {
|
a.pgxl, a.spe, a.acom = nil, nil, nil
|
||||||
go a.spe.Stop()
|
list, err := a.GetAmplifiers()
|
||||||
a.spe = nil
|
if err != nil {
|
||||||
}
|
|
||||||
if a.acom != nil {
|
|
||||||
go a.acom.Stop()
|
|
||||||
a.acom = nil
|
|
||||||
}
|
|
||||||
s, err := a.GetPGXLSettings()
|
|
||||||
if err != nil || !s.Enabled {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.Type == "" || s.Type == "pgxl" {
|
for _, c := range list {
|
||||||
if strings.TrimSpace(s.Host) == "" {
|
if !c.Enabled {
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
a.pgxl = powergenius.New(s.Host, s.Port)
|
isPGXL := c.Type == "" || c.Type == "pgxl"
|
||||||
_ = a.pgxl.Start()
|
if !isPGXL && c.Transport == "serial" && strings.TrimSpace(c.ComPort) == "" {
|
||||||
return
|
continue
|
||||||
|
}
|
||||||
|
if (isPGXL || c.Transport == "tcp") && strings.TrimSpace(c.Host) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inst := &Inst{cfg: c}
|
||||||
|
switch {
|
||||||
|
case isPGXL:
|
||||||
|
inst.pgxl = powergenius.New(c.Host, c.Port)
|
||||||
|
_ = inst.pgxl.Start()
|
||||||
|
if a.pgxl == nil {
|
||||||
|
a.pgxl = inst.pgxl
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(c.Type, "acom"):
|
||||||
|
inst.acom = acom.New(acom.Config{Model: strings.TrimPrefix(c.Type, "acom") + "S", Transport: c.Transport, ComPort: c.ComPort, Baud: c.Baud, Host: c.Host, Port: c.Port})
|
||||||
|
_ = inst.acom.Start()
|
||||||
|
if a.acom == nil {
|
||||||
|
a.acom = inst.acom
|
||||||
|
}
|
||||||
|
default: // spe*
|
||||||
|
inst.spe = spe.New(spe.Config{Transport: c.Transport, ComPort: c.ComPort, Baud: c.Baud, Host: c.Host, Port: c.Port})
|
||||||
|
_ = inst.spe.Start()
|
||||||
|
if a.spe == nil {
|
||||||
|
a.spe = inst.spe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.ampsMu.Lock()
|
||||||
|
a.ampInsts[c.ID] = inst
|
||||||
|
a.ampsMu.Unlock()
|
||||||
}
|
}
|
||||||
// SPE Expert / ACOM — USB serial or an RS232-to-Ethernet bridge.
|
}
|
||||||
if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" {
|
|
||||||
return
|
// AmpStatus is one amp's live state for the UI poll — exactly one of the
|
||||||
|
// per-family payloads is set, per the amp's type.
|
||||||
|
type AmpStatus struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
PGXL *powergenius.Status `json:"pgxl,omitempty"`
|
||||||
|
SPE *spe.Status `json:"spe,omitempty"`
|
||||||
|
ACOM *acom.Status `json:"acom,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAmpStatuses returns the live state of every ENABLED amplifier, in the
|
||||||
|
// configured order — one poll feeds every amp card and the status-bar chips.
|
||||||
|
func (a *App) GetAmpStatuses() []AmpStatus {
|
||||||
|
list, _ := a.GetAmplifiers()
|
||||||
|
a.ampsMu.Lock()
|
||||||
|
insts := a.ampInsts
|
||||||
|
a.ampsMu.Unlock()
|
||||||
|
out := []AmpStatus{}
|
||||||
|
for _, c := range list {
|
||||||
|
if !c.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
st := AmpStatus{ID: c.ID, Name: c.Name, Type: c.Type}
|
||||||
|
if inst, ok := insts[c.ID]; ok {
|
||||||
|
switch {
|
||||||
|
case inst.pgxl != nil:
|
||||||
|
v := inst.pgxl.GetStatus()
|
||||||
|
st.PGXL = &v
|
||||||
|
case inst.spe != nil:
|
||||||
|
v := inst.spe.GetStatus()
|
||||||
|
st.SPE = &v
|
||||||
|
case inst.acom != nil:
|
||||||
|
v := inst.acom.GetStatus()
|
||||||
|
st.ACOM = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, st)
|
||||||
}
|
}
|
||||||
if s.Transport == "tcp" && strings.TrimSpace(s.Host) == "" {
|
return out
|
||||||
return
|
}
|
||||||
|
|
||||||
|
func (a *App) ampInstByID(id string) *ampInst {
|
||||||
|
a.ampsMu.Lock()
|
||||||
|
defer a.ampsMu.Unlock()
|
||||||
|
return a.ampInsts[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// AmpOperate puts the given amp in OPERATE (true) or STANDBY (false).
|
||||||
|
func (a *App) AmpOperate(id string, on bool) error {
|
||||||
|
inst := a.ampInstByID(id)
|
||||||
|
if inst == nil {
|
||||||
|
return fmt.Errorf("amplifier not running — check Settings → Amplifier")
|
||||||
}
|
}
|
||||||
if model, ok := strings.CutPrefix(s.Type, "acom"); ok {
|
switch {
|
||||||
a.acom = acom.New(acom.Config{Model: model + "S", Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port})
|
case inst.pgxl != nil:
|
||||||
_ = a.acom.Start()
|
return inst.pgxl.SetOperate(on)
|
||||||
return
|
case inst.spe != nil:
|
||||||
|
return inst.spe.Operate(on)
|
||||||
|
case inst.acom != nil:
|
||||||
|
return inst.acom.Operate(on)
|
||||||
}
|
}
|
||||||
a.spe = spe.New(spe.Config{Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port})
|
return fmt.Errorf("amplifier not running")
|
||||||
_ = a.spe.Start()
|
}
|
||||||
|
|
||||||
|
// AmpPower turns the given amp on/off (SPE and ACOM; the PGXL has no power
|
||||||
|
// command on its direct link).
|
||||||
|
func (a *App) AmpPower(id string, on bool) error {
|
||||||
|
inst := a.ampInstByID(id)
|
||||||
|
if inst == nil {
|
||||||
|
return fmt.Errorf("amplifier not running — check Settings → Amplifier")
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case inst.spe != nil:
|
||||||
|
if on {
|
||||||
|
return inst.spe.PowerOn()
|
||||||
|
}
|
||||||
|
return inst.spe.PowerOff()
|
||||||
|
case inst.acom != nil:
|
||||||
|
if on {
|
||||||
|
return inst.acom.PowerOn()
|
||||||
|
}
|
||||||
|
return inst.acom.PowerOff()
|
||||||
|
}
|
||||||
|
return fmt.Errorf("power on/off is not available for this amplifier")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AmpPowerLevel selects the output power level — SPE only (L/M/H).
|
||||||
|
func (a *App) AmpPowerLevel(id, level string) error {
|
||||||
|
inst := a.ampInstByID(id)
|
||||||
|
if inst == nil || inst.spe == nil {
|
||||||
|
return fmt.Errorf("power level is an SPE feature")
|
||||||
|
}
|
||||||
|
return inst.spe.SetPowerLevel(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AmpFanMode sets the fan mode — PGXL only (STANDARD/CONTEST/BROADCAST).
|
||||||
|
func (a *App) AmpFanMode(id, mode string) error {
|
||||||
|
inst := a.ampInstByID(id)
|
||||||
|
if inst == nil || inst.pgxl == nil {
|
||||||
|
return fmt.Errorf("fan mode is a PowerGenius feature")
|
||||||
|
}
|
||||||
|
return inst.pgxl.SetFanMode(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSPEStatus returns the SPE Expert amplifier state for the UI poll.
|
// GetSPEStatus returns the SPE Expert amplifier state for the UI poll.
|
||||||
@@ -12463,6 +12993,15 @@ func (a *App) GetPGXLStatus() powergenius.Status {
|
|||||||
return a.pgxl.GetStatus()
|
return a.pgxl.GetStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PGXLSetOperate puts the PowerGenius XL in OPERATE (true) or STANDBY (false)
|
||||||
|
// over its direct TCP link (no Flex needed).
|
||||||
|
func (a *App) PGXLSetOperate(on bool) error {
|
||||||
|
if a.pgxl == nil {
|
||||||
|
return fmt.Errorf("PowerGenius not connected — enable it in Settings → Amplifier")
|
||||||
|
}
|
||||||
|
return a.pgxl.SetOperate(on)
|
||||||
|
}
|
||||||
|
|
||||||
// PGXLSetFanMode sets the amplifier fan mode (STANDARD | CONTEST | BROADCAST).
|
// PGXLSetFanMode sets the amplifier fan mode (STANDARD | CONTEST | BROADCAST).
|
||||||
func (a *App) PGXLSetFanMode(mode string) error {
|
func (a *App) PGXLSetFanMode(mode string) error {
|
||||||
if a.pgxl == nil {
|
if a.pgxl == nil {
|
||||||
@@ -12515,13 +13054,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"
|
||||||
}
|
}
|
||||||
@@ -12588,6 +13131,8 @@ 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
|
||||||
@@ -12605,7 +13150,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.
|
||||||
@@ -12994,7 +13545,14 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
entities, err := a.qso.EntitySlotMap(a.ctx, keyFor)
|
// Optional digital-mode grouping (Settings → General): with it on, FT8/FT4/
|
||||||
|
// RTTY… all count as ONE "DIG" mode, so an FT4 spot on a band where FT8 was
|
||||||
|
// worked shows "worked", not "new-slot" — DXCC-style mode classes.
|
||||||
|
var normMode func(string) string
|
||||||
|
if a.groupDigitalSlots() {
|
||||||
|
normMode = qso.GroupDigitalMode
|
||||||
|
}
|
||||||
|
entities, err := a.qso.EntitySlotMap(a.ctx, keyFor, normMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -13062,12 +13620,17 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
}
|
}
|
||||||
// Band already worked. If this MODE was never worked on the entity (any
|
// Band already worked. If this MODE was never worked on the entity (any
|
||||||
// band) → new-mode. If the mode was worked elsewhere but not on THIS
|
// band) → new-mode. If the mode was worked elsewhere but not on THIS
|
||||||
// band+mode → new-slot. Otherwise → worked.
|
// band+mode → new-slot. Otherwise → worked. The check mode goes through
|
||||||
if _, m := e.Modes[out[i].Mode]; !m {
|
// the same normaliser as the slot map (digital grouping).
|
||||||
|
checkMode := out[i].Mode
|
||||||
|
if normMode != nil {
|
||||||
|
checkMode = normMode(checkMode)
|
||||||
|
}
|
||||||
|
if _, m := e.Modes[checkMode]; !m {
|
||||||
out[i].Status = "new-mode"
|
out[i].Status = "new-mode"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := e.Slots[out[i].Band][out[i].Mode]; !ok {
|
if _, ok := e.Slots[out[i].Band][checkMode]; !ok {
|
||||||
out[i].Status = "new-slot"
|
out[i].Status = "new-slot"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+100
@@ -1,4 +1,104 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"date": "2026-07-22",
|
||||||
|
"en": [
|
||||||
|
"The PGXL amplifier chip and Station Control widget now read the OPERATE state from the FlexRadio (like the Flex panel) — the chip no longer shows STANDBY while the amp is operating, and clicking it toggles the amp through the radio.",
|
||||||
|
"Right-click 'Send to LoTW / QRZ / Club Log…' now reports its result as a notification — a failed upload (e.g. TQSL not configured) was completely silent outside the QSL Manager, which looked like nothing was sent.",
|
||||||
|
"The number of selected QSOs is now always visible at the top of the log grid — no need to open the right-click menu to see it.",
|
||||||
|
"NET Control: the worked-before list gets the same right-click menu as Recent QSOs (update from cty/QRZ/Club Log, send to services, recording e-mail, eQSL, delete).",
|
||||||
|
"Leaving compact mode restores the window's previous size AND position (it used to snap to a fixed size wherever the compact strip had been dragged).",
|
||||||
|
"The advanced filter's 'My callsign' field is renamed 'Station callsign' so it is findable under its ADIF name (it filters STATION_CALLSIGN).",
|
||||||
|
"Filtering now shows EVERY match: the on-screen row limit (Max) only applies to the unfiltered log — a filter matching 200 QSOs displays all 200 even with Max at 100 (safety cap 10,000, with a warning to narrow the filter beyond that).",
|
||||||
|
"New 'Select all' button in the log grid toolbar — selects every displayed row (respecting active column filters) in one click, ready for send-to-LoTW, bulk edit or export; once everything is selected it flips to 'Unselect all'.",
|
||||||
|
"NET Control: drag & drop between the two lists — drag a roster station onto the on-air list to start its QSO, and drag an on-air station onto the roster to log it (same as the Log & end button).",
|
||||||
|
"New option (Settings → General): 'Group digital modes as one (DXCC-style)' — when on, the matrix newness badges and the cluster new/new-band/new-mode/new-slot colouring treat FT8/FT4/RTTY/PSK… as a single Digital mode, matching how DXCC counts; when off (default), each digital mode remains its own potential slot.",
|
||||||
|
"Multiple amplifiers: Settings → Amplifier is now a LIST — configure several amps (e.g. two SPEs run in parallel), each with its own name and connection. The amp cards in the Flex panel and Station Control get a dropdown to pick which amp they show, and the bottom status bar shows one clickable chip per amp. Existing single-amp setups migrate automatically.",
|
||||||
|
"FlexRadio panel: the SmartSDR v4 DSP filters are now controllable — NRL and ANFL (legacy LMS), NRS (spectral subtraction), NRF (noise reduction with filter) with their level sliders, plus RNN (AI noise reduction) and ANFT (FFT auto-notch) toggles. The section appears automatically on radios that support them (8000/Aurora series). Layout rebalanced: RIT/XIT moved to the Transmit column and the antenna selection now sits at the top of the Receive column, alongside a new DAX button (SmartSDR's transmit-bar DAX toggle — TX audio from DAX, for WSJT-X & co)."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"La pastille ampli PGXL et le widget Station Control lisent maintenant l'état OPERATE depuis le FlexRadio (comme le panneau Flex) — la pastille n'affiche plus STANDBY quand l'ampli est en service, et un clic bascule l'ampli via la radio.",
|
||||||
|
"Le clic droit « Send to LoTW / QRZ / Club Log… » affiche maintenant son résultat en notification — un envoi échoué (p. ex. TQSL non configuré) était totalement silencieux hors du QSL Manager, comme si rien n'était parti.",
|
||||||
|
"Le nombre de QSO sélectionnés est maintenant toujours visible en haut de la grille du log — plus besoin d'ouvrir le menu clic droit pour le voir.",
|
||||||
|
"NET Control : la liste worked-before dispose du même menu clic droit que QSO récents (mise à jour cty/QRZ/Club Log, envoi aux services, e-mail d'enregistrement, eQSL, suppression).",
|
||||||
|
"Quitter le mode compact restaure la taille ET la position précédentes de la fenêtre (avant, elle reprenait une taille fixe là où la barre compacte avait été déplacée).",
|
||||||
|
"Le champ « Mon indicatif » du filtre avancé est renommé « Station callsign » pour être trouvable sous son nom ADIF (il filtre STATION_CALLSIGN).",
|
||||||
|
"Le filtrage affiche maintenant TOUTES les correspondances : la limite d'affichage (Max) ne s'applique qu'au log non filtré — un filtre qui matche 200 QSO les affiche tous les 200 même avec Max à 100 (plafond de sécurité 10 000, avec un avertissement pour affiner au-delà).",
|
||||||
|
"Nouveau bouton « Tout sélectionner » dans la barre d'outils de la grille — sélectionne toutes les lignes affichées (en respectant les filtres de colonnes actifs) en un clic, prêt pour l'envoi LoTW, le bulk edit ou l'export ; une fois tout sélectionné il devient « Tout désélectionner ».",
|
||||||
|
"NET Control : glisser-déposer entre les deux listes — glisser une station du roster vers la liste on air démarre son QSO, et glisser une station on air vers le roster l'enregistre au log (comme le bouton Logger & terminer).",
|
||||||
|
"Nouvelle option (Réglages → Général) : « Regrouper les modes digitaux en un seul (style DXCC) » — activée, les badges de nouveauté de la matrice et le coloriage cluster new/new-band/new-mode/new-slot traitent FT8/FT4/RTTY/PSK… comme un seul mode Digital, comme le DXCC ; désactivée (défaut), chaque mode digital reste un slot potentiel distinct.",
|
||||||
|
"Plusieurs amplificateurs : Réglages → Amplificateur est maintenant une LISTE — configurez plusieurs amplis (p. ex. deux SPE en parallèle), chacun avec son nom et sa connexion. Les cartes ampli du panneau Flex et de Station Control ont une liste déroulante pour choisir lequel afficher, et la barre du bas montre une pastille cliquable par ampli. Les configurations mono-ampli existantes migrent automatiquement.",
|
||||||
|
"Panneau FlexRadio : les filtres DSP SmartSDR v4 sont maintenant pilotables — NRL et ANFL (LMS legacy), NRS (soustraction spectrale), NRF (réduction de bruit avec filtre) avec leurs curseurs de niveau, plus les interrupteurs RNN (réduction de bruit par IA) et ANFT (notch automatique FFT). La section apparaît automatiquement sur les radios qui les supportent (séries 8000/Aurora). Mise en page rééquilibrée : RIT/XIT passent dans la colonne Transmit et la sélection d'antennes s'affiche en haut de la colonne Receive, avec un nouveau bouton DAX (le toggle DAX du bandeau transmit de SmartSDR — audio d'émission via DAX, pour WSJT-X & co)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.20.8",
|
||||||
|
"date": "2026-07-21",
|
||||||
|
"en": [
|
||||||
|
"New: microHAM ARCO rotator controlled natively — no PstRotator needed. Over the LAN or over USB: set the ARCO's CONTROL PROTOCOL (LAN or USB) to 'Yaesu GS-232A' and pick 'microHAM ARCO' in Settings → Rotator.",
|
||||||
|
"Amplifier without a FlexRadio: the amplifier controls (OPERATE/STANDBY, ON/OFF, live power/SWR/temperature) now also live in the Station Control tab, and a status chip sits in the bottom bar next to Cluster/CAT/Rotator — green ON AIR = OPERATE, orange = STANDBY, red = offline. Clicking the chip toggles OPERATE/STANDBY on all three amps (PowerGenius XL included, over its direct network link).",
|
||||||
|
"NET Control: after logging a station, the next on-air station is selected automatically (chain log → log → log), and a new 'Log everyone' button logs every on-air station at once when the net ends.",
|
||||||
|
"Switching to a profile whose MySQL database is unreachable now shows a clear warning — before, OpsLog silently stayed on the previous logbook and QSOs could land in the wrong log.",
|
||||||
|
"The ON AIR badge is back for local (SQLite) logbooks — it had disappeared with the removal of the live-status option."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Nouveau : rotator microHAM ARCO contrôlé en natif — sans PstRotator. Par le réseau ou par USB : régler le CONTROL PROTOCOL de l'ARCO (LAN ou USB) sur « Yaesu GS-232A » et choisir « microHAM ARCO » dans Réglages → Rotator.",
|
||||||
|
"Amplificateur sans FlexRadio : les commandes d'ampli (OPERATE/STANDBY, ON/OFF, puissance/ROS/température en direct) sont maintenant aussi dans l'onglet Station Control, et une pastille de statut s'affiche dans la barre du bas à côté de Cluster/CAT/Rotator — vert = OPERATE, orange = STANDBY, rouge = hors ligne. Un clic sur la pastille bascule OPERATE/STANDBY sur les trois amplis (PowerGenius XL compris, via son lien réseau direct).",
|
||||||
|
"NET Control : après avoir loggé une station, la station on air suivante est sélectionnée automatiquement (enchaînement log → log → log), et un nouveau bouton « Logger tout le monde » enregistre toutes les stations on air d'un coup à la fin du net.",
|
||||||
|
"Passer sur un profil dont la base MySQL est injoignable affiche maintenant un avertissement clair — avant, OpsLog restait silencieusement sur l'ancien logbook et les QSO pouvaient partir dans le mauvais log.",
|
||||||
|
"Le badge ON AIR est de retour pour les logbooks locaux (SQLite) — il avait disparu avec la suppression de l'option de statut en direct."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.20.7",
|
"version": "0.20.7",
|
||||||
"date": "2026-07-21",
|
"date": "2026-07-21",
|
||||||
|
|||||||
+198
-31
@@ -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,
|
||||||
@@ -43,6 +44,8 @@ import {
|
|||||||
GetAwardDefs,
|
GetAwardDefs,
|
||||||
GetUIPref, GetActiveProfile, QuitApp,
|
GetUIPref, GetActiveProfile, QuitApp,
|
||||||
ReportLiveActivity, LiveLastQSOAgeSec,
|
ReportLiveActivity, LiveLastQSOAgeSec,
|
||||||
|
GetAmpStatuses, AmpOperate,
|
||||||
|
GetFlexState, FlexAmpOperate,
|
||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||||
@@ -114,6 +117,9 @@ type ModePreset = ModePresetForm;
|
|||||||
type WB = WorkedBeforeView;
|
type WB = WorkedBeforeView;
|
||||||
type CATState = Omit<catModels.RigState, 'convertValues'>;
|
type CATState = Omit<catModels.RigState, 'convertValues'>;
|
||||||
|
|
||||||
|
// Safety cap on how many rows an ACTIVE filter may load (the normal row
|
||||||
|
// threshold is bypassed while filtering — see buildActiveFilter).
|
||||||
|
const FILTERED_LIMIT_CAP = 10000;
|
||||||
const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm'];
|
const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm'];
|
||||||
const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE'];
|
const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE'];
|
||||||
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
|
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
|
||||||
@@ -436,6 +442,22 @@ export default function App() {
|
|||||||
// click reverts the UI and the click looks like it did nothing.
|
// click reverts the UI and the click looks like it did nothing.
|
||||||
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
|
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
|
||||||
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
|
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
|
||||||
|
// Amplifier chips in the status bar — ONE PER configured amp (several are
|
||||||
|
// possible: some ops run two SPEs in parallel). Green OPERATE / orange
|
||||||
|
// STANDBY / red offline; click toggles. The Flex state rides along because a
|
||||||
|
// PGXL's OPERATE state comes from the radio when it reports the amp.
|
||||||
|
const [ampSts, setAmpSts] = useState<any[]>([]);
|
||||||
|
const [flexAmp, setFlexAmp] = useState<any>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = () => Promise.all([
|
||||||
|
GetAmpStatuses().catch(() => []),
|
||||||
|
GetFlexState().catch(() => null),
|
||||||
|
]).then(([l, fx]: any[]) => { if (alive) { setAmpSts((l ?? []) as any[]); setFlexAmp(fx); } });
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 2000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
// Multi-op "who's on air" widget: every operator's live status from the shared
|
// Multi-op "who's on air" widget: every operator's live status from the shared
|
||||||
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
|
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
|
||||||
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
|
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
|
||||||
@@ -929,14 +951,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?.(); };
|
||||||
@@ -948,7 +975,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).
|
||||||
@@ -1166,6 +1234,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);
|
||||||
@@ -1390,13 +1467,21 @@ export default function App() {
|
|||||||
|
|
||||||
// The full filter sent to the backend = the builder conditions + the quick
|
// The full filter sent to the backend = the builder conditions + the quick
|
||||||
// callsign search box (always ANDed) + the on-screen row threshold.
|
// callsign search box (always ANDed) + the on-screen row threshold.
|
||||||
const buildActiveFilter = useCallback((): QueryFilter => ({
|
// When a filter is ACTIVE the row threshold is lifted to a high safety cap:
|
||||||
quick_callsign: filterCallsign,
|
// the threshold exists to keep the UNFILTERED 30k-QSO log fast, but applying
|
||||||
conditions: activeFilter.conditions ?? [],
|
// it to a filter meant "200 matches, only the first 100 shown". A filtered
|
||||||
match: activeFilter.match ?? 'AND',
|
// result set is almost always small, and the cap protects against a
|
||||||
limit: qsoLimit,
|
// broad filter (e.g. band=20m) pulling half the log.
|
||||||
offset: 0,
|
const buildActiveFilter = useCallback((): QueryFilter => {
|
||||||
}), [filterCallsign, activeFilter, qsoLimit]);
|
const hasFilter = !!(filterCallsign || (activeFilter.conditions ?? []).length);
|
||||||
|
return {
|
||||||
|
quick_callsign: filterCallsign,
|
||||||
|
conditions: activeFilter.conditions ?? [],
|
||||||
|
match: activeFilter.match ?? 'AND',
|
||||||
|
limit: hasFilter ? FILTERED_LIMIT_CAP : qsoLimit,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
}, [filterCallsign, activeFilter, qsoLimit]);
|
||||||
|
|
||||||
// refresh reloads the grid. Returns false on failure. When silent, it doesn't
|
// refresh reloads the grid. Returns false on failure. When silent, it doesn't
|
||||||
// surface the error — used by the startup retry, since the logbook DB (a remote
|
// surface the error — used by the startup retry, since the logbook DB (a remote
|
||||||
@@ -2045,7 +2130,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 */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -2837,9 +2922,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, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
|
||||||
|
|
||||||
function handleMenu(action: string) {
|
function handleMenu(action: string) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -2868,6 +2959,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2911,8 +3017,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;
|
||||||
@@ -3688,7 +3796,12 @@ export default function App() {
|
|||||||
case 'netcontrol':
|
case 'netcontrol':
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border">
|
<div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border">
|
||||||
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
|
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
|
||||||
|
qsoMenuHandlers={{
|
||||||
|
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog,
|
||||||
|
onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
|
||||||
|
onDelete: (ids: number[]) => setDeletingIds(ids),
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'recent':
|
case 'recent':
|
||||||
@@ -4383,7 +4496,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' && (
|
||||||
@@ -4466,13 +4583,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>
|
||||||
)}
|
)}
|
||||||
@@ -4537,6 +4659,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>{/* /entry + aside row */}
|
</div>{/* /entry + aside row */}
|
||||||
|
|
||||||
@@ -4755,9 +4878,13 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{qsos.length >= qsoLimit && qsos.length < total && (
|
{(activeFilter.conditions?.length || filterCallsign)
|
||||||
<span className="text-warning">Limit reached — raise Max to see more.</span>
|
? (matchCount != null && matchCount > FILTERED_LIMIT_CAP && (
|
||||||
)}
|
<span className="text-warning">{matchCount.toLocaleString('en-US')} matches — only {FILTERED_LIMIT_CAP.toLocaleString('en-US')} shown, narrow the filter (or use the filtered ADIF export).</span>
|
||||||
|
))
|
||||||
|
: (qsos.length >= qsoLimit && qsos.length < total && (
|
||||||
|
<span className="text-warning">Limit reached — raise Max to see more.</span>
|
||||||
|
))}
|
||||||
<Label className="text-[11px] text-muted-foreground">Max</Label>
|
<Label className="text-[11px] text-muted-foreground">Max</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -5011,7 +5138,12 @@ export default function App() {
|
|||||||
tune the rig. */}
|
tune the rig. */}
|
||||||
{netEnabled && (
|
{netEnabled && (
|
||||||
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
|
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||||
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
|
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
|
||||||
|
qsoMenuHandlers={{
|
||||||
|
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog,
|
||||||
|
onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
|
||||||
|
onDelete: (ids: number[]) => setDeletingIds(ids),
|
||||||
|
}} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -5122,16 +5254,51 @@ export default function App() {
|
|||||||
disabled={!rotatorHeading.enabled}
|
disabled={!rotatorHeading.enabled}
|
||||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
||||||
/>
|
/>
|
||||||
{liveStationsOn && (
|
{/* Amplifier chips — one per configured amp: green = OPERATE, orange =
|
||||||
<div
|
STANDBY, red = offline. CLICK toggles OPERATE ↔ STANDBY (optimistic
|
||||||
className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors',
|
flip, the 2s poll reconciles); offline → click opens the settings. */}
|
||||||
onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
|
{ampSts.map((a: any) => {
|
||||||
title={onAir ? t('live.onAirTip') : t('live.offlineTip')}
|
const isPGXL = !a.spe && !a.acom;
|
||||||
>
|
const viaFlex = isPGXL && !!flexAmp?.amp_available;
|
||||||
<span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} />
|
const raw = a.spe ?? a.acom ?? a.pgxl ?? { connected: false };
|
||||||
{onAir ? t('live.onAir') : t('live.offline')}
|
const connected = !!raw.connected || viaFlex;
|
||||||
</div>
|
const operate = viaFlex ? !!flexAmp.amp_operate : !!raw.operate;
|
||||||
)}
|
const dot = !connected ? 'bg-danger' : operate ? 'bg-success' : 'bg-warning';
|
||||||
|
const state = !connected ? (raw.last_error || 'offline') : operate ? 'OPERATE' : 'STANDBY';
|
||||||
|
const toggle = () => {
|
||||||
|
if (!connected) { setSettingsSection('pgxl'); setShowSettings(true); return; }
|
||||||
|
const want = !operate;
|
||||||
|
if (viaFlex) setFlexAmp((f: any) => ({ ...f, amp_operate: want }));
|
||||||
|
else setAmpSts((l) => l.map((x: any) => x.id === a.id
|
||||||
|
? { ...x, spe: x.spe && { ...x.spe, operate: want }, acom: x.acom && { ...x.acom, operate: want }, pgxl: x.pgxl && { ...x.pgxl, operate: want } }
|
||||||
|
: x));
|
||||||
|
(viaFlex ? FlexAmpOperate(want) : AmpOperate(a.id, want)).catch(() => {});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
type="button"
|
||||||
|
title={`${a.name}: ${state}${connected ? (operate ? ' — click for STANDBY' : ' — click for OPERATE') : ' — click for settings'}`}
|
||||||
|
onClick={toggle}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors border-border hover:bg-muted cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className={cn('size-2 rounded-full', dot)} />
|
||||||
|
{a.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* ON AIR badge: "did I log a QSO in the last 5 min" — meaningful on ANY
|
||||||
|
logbook backend (only the live_status PUBLISHING is MySQL-specific),
|
||||||
|
so it is always shown. Gating it on MySQL made it vanish for
|
||||||
|
local-SQLite operators when the Settings toggle was removed. */}
|
||||||
|
<div
|
||||||
|
className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors',
|
||||||
|
onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
|
||||||
|
title={onAir ? t('live.onAirTip') : t('live.offlineTip')}
|
||||||
|
>
|
||||||
|
<span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} />
|
||||||
|
{onAir ? t('live.onAir') : t('live.offline')}
|
||||||
|
</div>
|
||||||
{/* Toasts / errors: the status bar's free space is far wider than the
|
{/* Toasts / errors: the status bar's free space is far wider than the
|
||||||
header band they used to sit in. Still one line (the bar is 28px),
|
header band they used to sit in. Still one line (the bar is 28px),
|
||||||
but CLICK opens the full text wrapped — long messages (a TQSL or
|
but CLICK opens the full text wrapped — long messages (a TQSL or
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -109,14 +109,21 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
// "Newness" of the current band+mode entry, for the award/DX-chase badges.
|
||||||
// Derived straight from the entity's real band_status (all bands it was
|
// Derived straight from the entity's real band_status (all bands it was
|
||||||
// worked on — not just the operator's configured column list).
|
// worked on — not just the operator's configured column list).
|
||||||
// Newness uses the ACTUAL mode (FT8 / FT4 / RTTY…), not the PH/CW/DIG class:
|
// By default newness uses the ACTUAL mode (FT8 / FT4 / RTTY…): DIG is a
|
||||||
// DIG is a group, so FT4 after FT8 is genuinely a new mode. dxcc_band_modes
|
// group, so FT4 after FT8 is genuinely a new mode. The operator can opt into
|
||||||
// lists every real (band, mode) the entity was worked on.
|
// DXCC-style grouping instead (Settings → General), where all digital modes
|
||||||
|
// count as ONE — then FT4 after FT8 is just "worked".
|
||||||
|
const groupDigital = localStorage.getItem('opslog.groupDigitalSlots') === '1';
|
||||||
|
const normMode = (m: string): string => {
|
||||||
|
const u = (m || '').toUpperCase().trim();
|
||||||
|
if (!groupDigital) return u;
|
||||||
|
return u === '' || u === 'CW' || PHONE_MODES.has(u) ? u : 'DIG';
|
||||||
|
};
|
||||||
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
|
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
|
||||||
const curMode = (currentMode || '').toUpperCase().trim();
|
const curMode = normMode(currentMode);
|
||||||
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
|
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
|
||||||
const modeWorked = !!curMode && bandModes.some((bm) => (bm.mode || '').toUpperCase() === curMode); // …in this exact mode (any band)
|
const modeWorked = !!curMode && bandModes.some((bm) => normMode(bm.mode) === curMode); // …in this (normalised) mode (any band)
|
||||||
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && (bm.mode || '').toUpperCase() === curMode);
|
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && normMode(bm.mode) === curMode);
|
||||||
// Mutually-exclusive badges, shown only when the entity is worked but this
|
// Mutually-exclusive badges, shown only when the entity is worked but this
|
||||||
// exact band+mode is NOT yet:
|
// exact band+mode is NOT yet:
|
||||||
// New Band & Mode = both the band AND the mode are new for this entity.
|
// New Band & Mode = both the band AND the mode are new for this entity.
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import {
|
|||||||
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||||
FlexMox, FlexAmpOperate,
|
FlexMox, FlexAmpOperate,
|
||||||
GetPGXLStatus, PGXLSetFanMode, GetPGXLSettings, GetSPEStatus, SPESetOperate, SPESetPower, SPESetPowerLevel,
|
GetPGXLStatus, PGXLSetFanMode,
|
||||||
GetACOMStatus, ACOMSetOperate, ACOMSetPower,
|
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
|
||||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
||||||
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
||||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||||
|
FlexSetLMSNR, FlexSetLMSNRLevel, FlexSetLMSANF, FlexSetLMSANFLevel,
|
||||||
|
FlexSetSpeexNR, FlexSetSpeexNRLevel, FlexSetRNN, FlexSetANFT, FlexSetNRF, FlexSetNRFLevel, FlexSetTXDAX,
|
||||||
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
|
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
|
||||||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||||
@@ -31,6 +33,11 @@ type FlexState = {
|
|||||||
rit: boolean; rit_freq: number; xit: boolean; xit_freq: number;
|
rit: boolean; rit_freq: number; xit: boolean; xit_freq: number;
|
||||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
||||||
wnb: boolean; wnb_level: number;
|
wnb: boolean; wnb_level: number;
|
||||||
|
// SmartSDR v4 DSP (8000/Aurora) — dsp_v4 is true once the radio reports them.
|
||||||
|
lms_nr?: boolean; lms_nr_level?: number; lms_anf?: boolean; lms_anf_level?: number;
|
||||||
|
speex_nr?: boolean; speex_nr_level?: number; rnn?: boolean; anft?: boolean;
|
||||||
|
nrf?: boolean; nrf_level?: number; dsp_v4?: boolean;
|
||||||
|
dax_ch?: number; tx_dax?: boolean;
|
||||||
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
|
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
|
||||||
mode?: string;
|
mode?: string;
|
||||||
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
||||||
@@ -342,39 +349,31 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Configured amplifier type (pgxl / spe*), so we show the SPE control when the
|
// Configured amplifiers (Settings → Amplifier) — possibly SEVERAL (some ops
|
||||||
// operator runs an SPE Expert instead of the PowerGenius.
|
// run two SPEs in parallel). The card shows ONE at a time; the dropdown picks
|
||||||
const [ampType, setAmpType] = useState<string>('pgxl');
|
// which, and the choice is remembered per panel.
|
||||||
const [ampEnabled, setAmpEnabled] = useState(false);
|
const [ampList, setAmpList] = useState<any[]>([]);
|
||||||
|
const [ampSel, setAmpSel] = useState<string>(() => localStorage.getItem('opslog.ampSel.flex') || '');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const load = () => GetPGXLSettings().then((s: any) => { if (alive) { setAmpType(s?.type || 'pgxl'); setAmpEnabled(!!s?.enabled); } }).catch(() => {});
|
const tick = () => GetAmpStatuses().then((l: any) => alive && setAmpList((l ?? []) as any[])).catch(() => {});
|
||||||
load();
|
tick();
|
||||||
const id = window.setInterval(load, 5000);
|
const id = window.setInterval(tick, 1500);
|
||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
const isACOM = ampEnabled && ampType.startsWith('acom');
|
const selAmp = ampList.find((a: any) => a.id === ampSel) ?? ampList[0];
|
||||||
const isSPE = ampEnabled && !isACOM && ampType !== 'pgxl';
|
const isSPE = !!selAmp?.spe;
|
||||||
// SPE Expert live status (only polled when an SPE amp is configured).
|
const isACOM = !!selAmp?.acom;
|
||||||
const [spe, setSpe] = useState<any>({ connected: false });
|
const spe = selAmp?.spe ?? { connected: false };
|
||||||
useEffect(() => {
|
const acom = selAmp?.acom ?? { connected: false };
|
||||||
if (!isSPE) return;
|
const ampPicker = ampList.length > 1 && selAmp ? (
|
||||||
let alive = true;
|
<select value={selAmp.id}
|
||||||
const tick = () => GetSPEStatus().then((s: any) => alive && setSpe(s || { connected: false })).catch(() => {});
|
onChange={(e) => { setAmpSel(e.target.value); localStorage.setItem('opslog.ampSel.flex', e.target.value); }}
|
||||||
tick();
|
title={t('flxp.ampPick')}
|
||||||
const id = window.setInterval(tick, 1500);
|
className="h-8 rounded-md border border-border bg-card px-2 text-xs font-semibold outline-none">
|
||||||
return () => { alive = false; window.clearInterval(id); };
|
{ampList.map((a: any) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
}, [isSPE]);
|
</select>
|
||||||
// ACOM live status (only polled when an ACOM amp is configured).
|
) : null;
|
||||||
const [acom, setAcom] = useState<any>({ connected: false });
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isACOM) return;
|
|
||||||
let alive = true;
|
|
||||||
const tick = () => GetACOMStatus().then((s: any) => alive && setAcom(s || { connected: false })).catch(() => {});
|
|
||||||
tick();
|
|
||||||
const id = window.setInterval(tick, 1500);
|
|
||||||
return () => { alive = false; window.clearInterval(id); };
|
|
||||||
}, [isACOM]);
|
|
||||||
|
|
||||||
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
||||||
hold.current[key] = Date.now() + 900;
|
hold.current[key] = Date.now() + 900;
|
||||||
@@ -556,11 +555,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>
|
||||||
@@ -702,12 +702,24 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
|
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* RIT/XIT live on the TRANSMIT side to balance the two columns — the
|
||||||
|
RECEIVE card grew tall with the v4 DSP rows. */}
|
||||||
|
<div className="space-y-1.5 border-t border-border/60 pt-3">
|
||||||
|
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
|
||||||
|
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
|
||||||
|
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
|
||||||
|
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
|
||||||
|
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
|
||||||
|
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* RECEIVE */}
|
{/* RECEIVE */}
|
||||||
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2">
|
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2">
|
||||||
|
{/* Antenna selection sits at the very top of the RX column. */}
|
||||||
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
|
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 pb-3 border-b border-border/60">
|
||||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
|
||||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||||
<span className="text-[10px] text-muted-foreground">RX</span>
|
<span className="text-[10px] text-muted-foreground">RX</span>
|
||||||
@@ -722,21 +734,17 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
|
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
|
||||||
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
|
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
{/* DAX on/off — SmartSDR's transmit-bar DAX button (TX audio from
|
||||||
|
DAX, e.g. WSJT-X). "transmit set dax=", not the slice channel. */}
|
||||||
|
<button type="button" disabled={off} title={t('flxp.daxHint')}
|
||||||
|
onClick={() => change('tx_dax', !st.tx_dax, () => FlexSetTXDAX(!st.tx_dax))}
|
||||||
|
className={cn('h-7 px-2.5 shrink-0 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-40',
|
||||||
|
st.tx_dax ? 'bg-info text-info-foreground border-info' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
DAX
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* RIT / XIT — on the RECEIVE side: RIT is what you reach for while
|
|
||||||
listening (chasing a station that drifts off your transmit frequency),
|
|
||||||
so it belongs with the other things you touch mid-QSO. Both act on the
|
|
||||||
ACTIVE slice and follow slice focus like every control here. */}
|
|
||||||
<div className="space-y-1.5 pb-3 border-b border-border/60">
|
|
||||||
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
|
|
||||||
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
|
|
||||||
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
|
|
||||||
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
|
|
||||||
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
|
|
||||||
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
||||||
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
||||||
@@ -777,6 +785,49 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* SmartSDR v4 DSP (8000/Aurora series) — NRL/ANFL (legacy LMS), NRS
|
||||||
|
(spectral subtraction), NRF (NR w/ filter), RNN (AI NR), ANFT (FFT
|
||||||
|
notch). Only shown when the radio actually reports these slice keys
|
||||||
|
(older 6000s never do). API keys per the FlexLib slice docs. */}
|
||||||
|
{st.dsp_v4 && (
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<LevelRow label="NRL" on={!!st.lms_nr} disabled={rxOff} value={st.lms_nr_level ?? 0} accent="cyan" sliderAccent="#0e7490"
|
||||||
|
onToggle={() => change('lms_nr', !st.lms_nr, () => FlexSetLMSNR(!st.lms_nr))}
|
||||||
|
onLevel={(v) => change('lms_nr_level', v, () => FlexSetLMSNRLevel(v))} />
|
||||||
|
<LevelRow label="NRS" on={!!st.speex_nr} disabled={rxOff} value={st.speex_nr_level ?? 0} accent="cyan" sliderAccent="#06b6d4"
|
||||||
|
onToggle={() => change('speex_nr', !st.speex_nr, () => FlexSetSpeexNR(!st.speex_nr))}
|
||||||
|
onLevel={(v) => change('speex_nr_level', v, () => FlexSetSpeexNRLevel(v))} />
|
||||||
|
<LevelRow label="NRF" on={!!st.nrf} disabled={rxOff} value={st.nrf_level ?? 0} accent="cyan" sliderAccent="#0369a1"
|
||||||
|
onToggle={() => change('nrf', !st.nrf, () => FlexSetNRF(!st.nrf))}
|
||||||
|
onLevel={(v) => change('nrf_level', v, () => FlexSetNRFLevel(v))} />
|
||||||
|
{/* Notch filters target carriers in voice — hidden in CW like ANF. */}
|
||||||
|
{!isCW && (
|
||||||
|
<LevelRow label="ANFL" on={!!st.lms_anf} disabled={rxOff} value={st.lms_anf_level ?? 0} accent="violet" sliderAccent="#8b5cf6"
|
||||||
|
onToggle={() => change('lms_anf', !st.lms_anf, () => FlexSetLMSANF(!st.lms_anf))}
|
||||||
|
onLevel={(v) => change('lms_anf_level', v, () => FlexSetLMSANFLevel(v))} />
|
||||||
|
)}
|
||||||
|
{/* RNN and ANFT are on/off only — no level in the API. */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground" title={t('flxp.dspV4Hint')}>AI/FFT</span>
|
||||||
|
<button type="button" disabled={rxOff}
|
||||||
|
title={t('flxp.rnnHint')}
|
||||||
|
onClick={() => change('rnn', !st.rnn, () => FlexSetRNN(!st.rnn))}
|
||||||
|
className={cn('px-2.5 py-1 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-30',
|
||||||
|
st.rnn ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
RNN
|
||||||
|
</button>
|
||||||
|
{!isCW && (
|
||||||
|
<button type="button" disabled={rxOff}
|
||||||
|
title={t('flxp.anftHint')}
|
||||||
|
onClick={() => change('anft', !st.anft, () => FlexSetANFT(!st.anft))}
|
||||||
|
className={cn('px-2.5 py-1 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-30',
|
||||||
|
st.anft ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
ANFT
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isCW && (
|
{isCW && (
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
<LevelRow label="APF" on={st.apf} disabled={rxOff} value={st.apf_level} accent="emerald" sliderAccent="#16a34a"
|
<LevelRow label="APF" on={st.apf} disabled={rxOff} value={st.apf_level} accent="emerald" sliderAccent="#16a34a"
|
||||||
@@ -830,10 +881,11 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
The Flex doesn't report SPE amps, so this card is driven by OpsLog's own
|
The Flex doesn't report SPE amps, so this card is driven by OpsLog's own
|
||||||
SPE link rather than the Flex amplifier object. */}
|
SPE link rather than the Flex amplifier object. */}
|
||||||
{isSPE && (
|
{isSPE && (
|
||||||
<Card icon={Flame} title={`${t('flxp.amplifier')} · SPE${spe.model ? ' ' + spe.model : ''}`} accent="#ea580c">
|
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${selAmp?.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{ampPicker}
|
||||||
<button type="button" disabled={!spe.connected}
|
<button type="button" disabled={!spe.connected}
|
||||||
onClick={() => SPESetOperate(!spe.operate).catch(() => {})}
|
onClick={() => AmpOperate(selAmp.id, !spe.operate).catch(() => {})}
|
||||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
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 ? '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'}
|
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
@@ -841,10 +893,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
{/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */}
|
{/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */}
|
||||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||||
<button type="button" disabled={!spe.connected}
|
<button type="button" disabled={!spe.connected}
|
||||||
onClick={() => SPESetPower(true).catch(() => {})}
|
onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
|
||||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
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}
|
<button type="button" disabled={!spe.connected}
|
||||||
onClick={() => SPESetPower(false).catch(() => {})}
|
onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
|
||||||
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
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>
|
||||||
{/* Output power level: Low / Mid / High. */}
|
{/* Output power level: Low / Mid / High. */}
|
||||||
@@ -853,7 +905,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
const active = (spe.power_level || '').trim().toUpperCase() === lvl;
|
const active = (spe.power_level || '').trim().toUpperCase() === lvl;
|
||||||
return (
|
return (
|
||||||
<button key={lvl} type="button" disabled={!spe.connected}
|
<button key={lvl} type="button" disabled={!spe.connected}
|
||||||
onClick={() => SPESetPowerLevel(lvl).catch(() => {})}
|
onClick={() => AmpPowerLevel(selAmp.id, lvl).catch(() => {})}
|
||||||
className={cn('px-3 py-2 text-sm font-bold disabled:opacity-30', i > 0 && 'border-l border-border',
|
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')}>
|
active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
{powerLevelLabel(lvl)}
|
{powerLevelLabel(lvl)}
|
||||||
@@ -887,10 +939,11 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
{/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven
|
{/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven
|
||||||
by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */}
|
by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */}
|
||||||
{isACOM && (
|
{isACOM && (
|
||||||
<Card icon={Flame} title={`${t('flxp.amplifier')} · ACOM${acom.model ? ' ' + acom.model : ''}`} accent="#ea580c">
|
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${selAmp?.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{ampPicker}
|
||||||
<button type="button" disabled={!acom.connected}
|
<button type="button" disabled={!acom.connected}
|
||||||
onClick={() => ACOMSetOperate(!acom.operate).catch(() => {})}
|
onClick={() => AmpOperate(selAmp.id, !acom.operate).catch(() => {})}
|
||||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
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 ? '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'}
|
{acom.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
@@ -899,11 +952,11 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
is off (the port stays open), so gate on port_open not connected. */}
|
is off (the port stays open), so gate on port_open not connected. */}
|
||||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||||
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
|
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
|
||||||
onClick={() => ACOMSetPower(true).catch(() => {})}
|
onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
|
||||||
title={acom.transport === 'serial' ? 'Power on (DTR/RTS pulse — needs the power-on pins wired)' : 'Power-on needs the serial DTR/RTS lines — not available over a network bridge'}
|
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>
|
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}
|
<button type="button" disabled={!acom.connected}
|
||||||
onClick={() => ACOMSetPower(false).catch(() => {})}
|
onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
|
||||||
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
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>
|
||||||
<span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}>
|
<span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||||
@@ -935,6 +988,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
{st.amp_available && !isSPE && !isACOM && (
|
{st.amp_available && !isSPE && !isACOM && (
|
||||||
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{ampPicker}
|
||||||
<button type="button" disabled={off}
|
<button type="button" disabled={off}
|
||||||
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
|
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
|
||||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
@@ -947,7 +1001,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
||||||
PowerGenius). The dot shows the direct-connection state; the
|
PowerGenius). The dot shows the direct-connection state; the
|
||||||
selector is disabled until connected (hover it for the error). */}
|
selector is disabled until connected (hover it for the error). */}
|
||||||
{ampType === 'pgxl' && (pg.host || pg.connected) && (
|
{selAmp?.type === 'pgxl' && (pg.host || pg.connected) && (
|
||||||
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
||||||
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||||
<span className="text-muted-foreground">{t('flxp.fan')}</span>
|
<span className="text-muted-foreground">{t('flxp.fan')}</span>
|
||||||
|
|||||||
@@ -42,14 +42,28 @@ function fmtTimeOn(s: any): string {
|
|||||||
|
|
||||||
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
||||||
|
|
||||||
|
// The right-click actions of the main Recent QSOs grid (update from cty/QRZ/
|
||||||
|
// Club Log, send to services, recording e-mail, eQSL, delete) — passed down so
|
||||||
|
// the worked-before grid here offers the SAME context menu on logged QSOs.
|
||||||
|
type QSOMenuHandlers = {
|
||||||
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
|
onSendRecording?: (ids: number[]) => void;
|
||||||
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
onDelete?: (ids: number[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onLogged?: () => void;
|
onLogged?: () => void;
|
||||||
countries?: string[];
|
countries?: string[];
|
||||||
bands?: string[];
|
bands?: string[];
|
||||||
modes?: string[];
|
modes?: string[];
|
||||||
|
qsoMenuHandlers?: QSOMenuHandlers;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHandlers }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [nets, setNets] = useState<Net[]>([]);
|
const [nets, setNets] = useState<Net[]>([]);
|
||||||
const [selId, setSelId] = useState<string>('');
|
const [selId, setSelId] = useState<string>('');
|
||||||
@@ -61,6 +75,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
||||||
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
||||||
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
|
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
|
||||||
|
// Programmatic row selection in the on-air grid (auto-advance after logging).
|
||||||
|
const [selectRow, setSelectRow] = useState<{ id: number; seq: number }>({ id: 0, seq: 0 });
|
||||||
|
|
||||||
// Add/edit-contact dialog.
|
// Add/edit-contact dialog.
|
||||||
const [contactOpen, setContactOpen] = useState(false);
|
const [contactOpen, setContactOpen] = useState(false);
|
||||||
@@ -68,6 +84,34 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
const [looking, setLooking] = useState(false);
|
const [looking, setLooking] = useState(false);
|
||||||
|
|
||||||
const rosterGrid = useRef<any>(null);
|
const rosterGrid = useRef<any>(null);
|
||||||
|
// Cross-grid drag & drop: roster row → on-air grid puts the station on air;
|
||||||
|
// on-air row → roster grid logs it (same as the "Log & end" button). AG-Grid
|
||||||
|
// external drop zones need each grid's api + the OTHER grid's container div.
|
||||||
|
const onAirApi = useRef<any>(null);
|
||||||
|
const onAirWrap = useRef<HTMLDivElement | null>(null);
|
||||||
|
const rosterWrap = useRef<HTMLDivElement | null>(null);
|
||||||
|
const dropZonesDone = useRef({ roster: false, onair: false });
|
||||||
|
// Callbacks live in refs so the drop-zone closures (registered once) always
|
||||||
|
// call the CURRENT activate/deactivate, not a stale first-render one.
|
||||||
|
const activateRef = useRef<(call: string) => void>(() => {});
|
||||||
|
const deactivateRef = useRef<(id?: number) => void>(() => {});
|
||||||
|
const wireDropZones = useCallback(() => {
|
||||||
|
const rApi = rosterGrid.current?.api;
|
||||||
|
if (rApi && onAirWrap.current && !dropZonesDone.current.roster) {
|
||||||
|
dropZonesDone.current.roster = true;
|
||||||
|
rApi.addRowDropZone({
|
||||||
|
getContainer: () => onAirWrap.current!,
|
||||||
|
onDragStop: (p: any) => { const call = p.node?.data?.callsign; if (call) activateRef.current(call); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (onAirApi.current && rosterWrap.current && !dropZonesDone.current.onair) {
|
||||||
|
dropZonesDone.current.onair = true;
|
||||||
|
onAirApi.current.addRowDropZone({
|
||||||
|
getContainer: () => rosterWrap.current!,
|
||||||
|
onDragStop: (p: any) => { const id = p.node?.data?.id; if (id != null) deactivateRef.current(id); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Worked-before for the clicked station (see if/when we contacted it before).
|
// Worked-before for the clicked station (see if/when we contacted it before).
|
||||||
const [wbCall, setWbCall] = useState('');
|
const [wbCall, setWbCall] = useState('');
|
||||||
@@ -177,10 +221,38 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
// Active → logged (end QSO, removed from session, written to the logbook).
|
// Active → logged (end QSO, removed from session, written to the logbook).
|
||||||
|
// Then auto-select the FIRST remaining on-air station, so the operator can
|
||||||
|
// chain "log → log → log" without re-clicking a row each time.
|
||||||
async function deactivate(id?: number) {
|
async function deactivate(id?: number) {
|
||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
|
const next = active.find((a) => a.id !== id); // computed BEFORE the list shrinks
|
||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
try {
|
||||||
|
await NetDeactivate(id);
|
||||||
|
await refreshActive();
|
||||||
|
onLogged?.();
|
||||||
|
if (next?.id != null) {
|
||||||
|
setSelectRow((s) => ({ id: next.id as number, seq: s.seq + 1 }));
|
||||||
|
setSelectedActiveIds([next.id as number]);
|
||||||
|
showWorkedBefore(next.callsign, (next as any).dxcc ?? 0);
|
||||||
|
}
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
// Keep the drop-zone closures pointed at the CURRENT handlers.
|
||||||
|
activateRef.current = activate;
|
||||||
|
deactivateRef.current = deactivate;
|
||||||
|
|
||||||
|
// Log EVERYONE still on air (end of net): each draft is written to the logbook
|
||||||
|
// exactly as the single-log button would.
|
||||||
|
async function logAll() {
|
||||||
|
if (active.length === 0) return;
|
||||||
|
if (!window.confirm(t('ncp.logAllConfirm', { n: active.length }))) return;
|
||||||
|
try {
|
||||||
|
for (const a of [...active]) {
|
||||||
|
if (a.id != null) await NetDeactivate(a.id as number);
|
||||||
|
}
|
||||||
|
await refreshActive();
|
||||||
|
onLogged?.();
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); await refreshActive(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit-modal handlers (operate on the in-memory draft, not the DB).
|
// Edit-modal handlers (operate on the in-memory draft, not the DB).
|
||||||
@@ -232,7 +304,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||||
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
// rowDrag: drag a roster station onto the on-air grid to start its QSO.
|
||||||
|
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold', rowDrag: true },
|
||||||
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
{ headerName: t('ncp.colName'), field: 'name', flex: 1 },
|
||||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||||
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
|
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
|
||||||
@@ -281,11 +354,14 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
{t('ncp.onAirActive')}
|
{t('ncp.onAirActive')}
|
||||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-h-0 flex-1">
|
<div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
rows={active}
|
rows={active}
|
||||||
total={active.length}
|
total={active.length}
|
||||||
storageKey="net.onair"
|
storageKey="net.onair"
|
||||||
|
selectRowSignal={selectRow}
|
||||||
|
rowDragCall
|
||||||
|
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
|
||||||
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
||||||
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
||||||
onRowSelected={setSelectedActiveIds}
|
onRowSelected={setSelectedActiveIds}
|
||||||
@@ -297,6 +373,9 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
|
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
|
||||||
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto" onClick={logAll}>
|
||||||
|
<MinusCircle className="size-3.5" /> {t('ncp.logAll', { n: active.length })}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -330,6 +409,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
rows={(wb.entries ?? []) as any}
|
rows={(wb.entries ?? []) as any}
|
||||||
total={wb.count}
|
total={wb.count}
|
||||||
storageKey="net.wb"
|
storageKey="net.wb"
|
||||||
|
{...qsoMenuHandlers}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -343,7 +423,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
{t('ncp.netUsersRoster')}
|
{t('ncp.netUsersRoster')}
|
||||||
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<div ref={rosterWrap} style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
<div style={{ position: 'absolute', inset: 0 }}>
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
<AgGridReact<Station>
|
<AgGridReact<Station>
|
||||||
ref={rosterGrid}
|
ref={rosterGrid}
|
||||||
@@ -352,6 +432,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
columnDefs={rosterCols}
|
columnDefs={rosterCols}
|
||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||||
|
onGridReady={() => wireDropZones()}
|
||||||
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
||||||
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3, FilterX } from 'lucide-react';
|
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +30,14 @@ type Props = {
|
|||||||
// Bump this number to programmatically select every row (e.g. after a search
|
// Bump this number to programmatically select every row (e.g. after a search
|
||||||
// in the QSL Manager, where the default is "all selected").
|
// in the QSL Manager, where the default is "all selected").
|
||||||
selectAllSignal?: number;
|
selectAllSignal?: number;
|
||||||
|
// Bump `seq` to programmatically select the single row with this id (e.g. NET
|
||||||
|
// Control auto-advancing to the next on-air station after logging one).
|
||||||
|
selectRowSignal?: { id: number; seq: number };
|
||||||
|
// Show a row-drag handle on the callsign column (NET Control: drag an on-air
|
||||||
|
// row onto the roster to log it). Pair with onGridApi so the parent can
|
||||||
|
// register external drop zones via api.addRowDropZone.
|
||||||
|
rowDragCall?: boolean;
|
||||||
|
onGridApi?: (api: any) => void;
|
||||||
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
||||||
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
||||||
// reuse this grid elsewhere with its OWN independent column config.
|
// reuse this grid elsewhere with its OWN independent column config.
|
||||||
@@ -249,7 +257,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
|||||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
@@ -257,6 +265,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
// (e.g. the Net panel) keeps its own layout independent of the main log.
|
// (e.g. the Net panel) keeps its own layout independent of the main log.
|
||||||
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
|
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
|
const [selCount, setSelCount] = useState(0); // live selection count shown in the toolbar
|
||||||
|
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
||||||
|
|
||||||
// Localized column catalog — rebuilt when the language changes.
|
// Localized column catalog — rebuilt when the language changes.
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||||
@@ -316,7 +326,11 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
restoringRef.current = true;
|
restoringRef.current = true;
|
||||||
const base = COL_CATALOG.map((c) => {
|
const base = COL_CATALOG.map((c) => {
|
||||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||||
return { ...rest, hide: !defaultVisible };
|
const col: ColDef<QSOForm> = { ...rest, hide: !defaultVisible };
|
||||||
|
if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) {
|
||||||
|
col.rowDrag = true;
|
||||||
|
}
|
||||||
|
return col;
|
||||||
});
|
});
|
||||||
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
||||||
colId: `award_${a.code}`,
|
colId: `award_${a.code}`,
|
||||||
@@ -330,7 +344,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||||
}));
|
}));
|
||||||
return [...base, ...awards];
|
return [...base, ...awards];
|
||||||
}, [awardCols, COL_CATALOG, t, awardShown]);
|
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall]);
|
||||||
|
|
||||||
// Enforce award-column visibility via the API after the columns (re)appear —
|
// Enforce award-column visibility via the API after the columns (re)appear —
|
||||||
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
||||||
@@ -353,6 +367,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
function onGridReady(e: GridReadyEvent) {
|
||||||
|
onGridApi?.(e.api);
|
||||||
const local = loadLocal(colStateKey);
|
const local = loadLocal(colStateKey);
|
||||||
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
|
||||||
// Fall back to the portable DB copy when the local cache is empty
|
// Fall back to the portable DB copy when the local cache is empty
|
||||||
@@ -368,6 +383,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
// null when no column filter is active, so "Showing X of Y" reflects them.
|
// null when no column filter is active, so "Showing X of Y" reflects them.
|
||||||
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
const reportFilteredCount = useCallback((e: { api?: any }) => {
|
||||||
const api = e?.api ?? gridRef.current?.api;
|
const api = e?.api ?? gridRef.current?.api;
|
||||||
|
if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
|
||||||
if (!api || !onFilteredCountChange) return;
|
if (!api || !onFilteredCountChange) return;
|
||||||
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
|
||||||
}, [onFilteredCountChange]);
|
}, [onFilteredCountChange]);
|
||||||
@@ -397,7 +413,10 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
}
|
}
|
||||||
function onSelectionChanged() {
|
function onSelectionChanged() {
|
||||||
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
const api = gridRef.current?.api;
|
||||||
|
const sel = (api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||||
|
setSelCount(sel.length);
|
||||||
|
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
|
||||||
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
||||||
}
|
}
|
||||||
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
|
||||||
@@ -406,6 +425,21 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
gridRef.current?.api?.selectAll();
|
gridRef.current?.api?.selectAll();
|
||||||
}, [selectAllSignal]);
|
}, [selectAllSignal]);
|
||||||
|
|
||||||
|
// Select ONE row by id when the caller bumps selectRowSignal.seq. Deferred a
|
||||||
|
// tick so a row-data refresh in the same render settles first.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectRowSignal || selectRowSignal.seq === 0) return;
|
||||||
|
const t = window.setTimeout(() => {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api) return;
|
||||||
|
api.deselectAll();
|
||||||
|
api.forEachNode((n: any) => {
|
||||||
|
if (n.data?.id === selectRowSignal.id) n.setSelected(true);
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [selectRowSignal?.seq]);
|
||||||
|
|
||||||
// ── Column picker (visibility) ──
|
// ── Column picker (visibility) ──
|
||||||
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
|
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
|
||||||
// state for "which columns are visible" — AG Grid's column state is the
|
// state for "which columns are visible" — AG Grid's column state is the
|
||||||
@@ -462,6 +496,32 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
|
||||||
|
{/* Live selection count — visible without opening the context menu. */}
|
||||||
|
{selCount > 0 && (
|
||||||
|
<span className="mr-auto text-[11px] font-semibold text-primary tabular-nums px-1.5">
|
||||||
|
{t('rqg.selectedCount', { n: selCount })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Select every loaded row that passes the active column filters — so a
|
||||||
|
filtered view can be selected in one click (then send to LoTW, bulk
|
||||||
|
edit, export…). Once everything is selected the same button flips to
|
||||||
|
"Unselect all". */}
|
||||||
|
{(() => {
|
||||||
|
const allSelected = selCount > 0 && dispCount > 0 && selCount >= dispCount;
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||||
|
title={allSelected ? t('rqg.unselectAllTitle') : t('rqg.selectAllTitle')}
|
||||||
|
onClick={() => {
|
||||||
|
const api: any = gridRef.current?.api;
|
||||||
|
if (!api) return;
|
||||||
|
if (allSelected) api.deselectAll();
|
||||||
|
else if (typeof api.selectAllFiltered === 'function') api.selectAllFiltered();
|
||||||
|
else api.selectAll('filtered');
|
||||||
|
}}>
|
||||||
|
<ListChecks className="size-3.5" /> {allSelected ? t('rqg.unselectAll') : t('rqg.selectAll')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
|
||||||
title={t('rqg.clearFiltersTitle')}>
|
title={t('rqg.clearFiltersTitle')}>
|
||||||
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
|
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
GetPGXLSettings, SavePGXLSettings, GetSPEStatus, SPESetOperate, GetACOMStatus, ACOMSetOperate,
|
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||||
@@ -620,6 +620,9 @@ function ADIFMonitorPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AmpUI mirrors the backend AmpConfig — one configured amplifier.
|
||||||
|
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number };
|
||||||
|
|
||||||
// RelayAutoPanel configures automatic control of the Station Control relay boards
|
// RelayAutoPanel configures automatic control of the Station Control relay boards
|
||||||
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
|
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
|
||||||
// off, a frequency window (kHz), or a set of bands.
|
// off, a frequency window (kHz), or a set of bands.
|
||||||
@@ -628,33 +631,38 @@ type StationDevUI = { id: string; type: string; name: string; labels: string[] }
|
|||||||
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
|
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
|
||||||
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5);
|
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5);
|
||||||
|
|
||||||
// Live SPE Expert amplifier status + OPERATE/STANDBY toggle. Module-scoped (not a
|
// Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config
|
||||||
// nested component) so it isn't remounted on every parent render. Polls once a
|
// id) — SPE / ACOM / PGXL alike. Module-scoped (not a nested component) so it
|
||||||
// second while shown.
|
// isn't remounted on every parent render. Polls once a second while shown.
|
||||||
function SPEStatusCard() {
|
function AmpStatusCard({ id }: { id: string }) {
|
||||||
const [st, setSt] = useState<any>({ connected: false });
|
const [amp, setAmp] = useState<any>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const tick = () => GetSPEStatus().then((s) => alive && setSt(s || {})).catch(() => {});
|
const tick = () => GetAmpStatuses().then((all: any[]) => {
|
||||||
|
if (alive) setAmp((all ?? []).find((a) => a.id === id) ?? null);
|
||||||
|
}).catch(() => {});
|
||||||
tick();
|
tick();
|
||||||
const id = window.setInterval(tick, 1000);
|
const t = window.setInterval(tick, 1000);
|
||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(t); };
|
||||||
}, []);
|
}, [id]);
|
||||||
|
const st: any = amp?.spe ?? amp?.acom ?? amp?.pgxl ?? { connected: false };
|
||||||
|
const isSPE = !!amp?.spe;
|
||||||
|
const isACOM = !!amp?.acom;
|
||||||
const operate = !!st.operate;
|
const operate = !!st.operate;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl">
|
<div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={cn('size-2 rounded-full', st.connected ? 'bg-success animate-pulse' : 'bg-danger')} />
|
<span className={cn('size-2 rounded-full', st.connected ? 'bg-success animate-pulse' : 'bg-danger')} />
|
||||||
<span className="font-semibold">{st.connected ? `SPE ${st.model || 'Expert'}` : 'SPE — not connected'}</span>
|
<span className="font-semibold">{amp?.name || 'Amplifier'}{st.connected ? '' : ' — not connected'}</span>
|
||||||
{!st.connected && st.last_error && <span className="text-danger truncate text-[10px]">{st.last_error}</span>}
|
{!st.connected && st.last_error && <span className="text-danger truncate text-[10px]">{st.last_error}</span>}
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
<Button size="sm" variant={operate ? 'default' : 'outline'} disabled={!st.connected}
|
<Button size="sm" variant={operate ? 'default' : 'outline'} disabled={!st.connected}
|
||||||
onClick={() => SPESetOperate(!operate).catch(() => {})}
|
onClick={() => AmpOperate(id, !operate).catch(() => {})}
|
||||||
title="Toggle OPERATE / STANDBY">
|
title="Toggle OPERATE / STANDBY">
|
||||||
{operate ? 'OPERATE' : 'STANDBY'}
|
{operate ? 'OPERATE' : 'STANDBY'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{st.connected && (
|
{st.connected && isSPE && (
|
||||||
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
|
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
|
||||||
<div>{st.tx ? 'TX' : 'RX'}</div>
|
<div>{st.tx ? 'TX' : 'RX'}</div>
|
||||||
<div>Band {st.band}</div>
|
<div>Band {st.band}</div>
|
||||||
@@ -667,35 +675,7 @@ function SPEStatusCard() {
|
|||||||
{(st.warnings || st.alarms) && <div className="col-span-4 text-warning">⚠ {st.warnings} {st.alarms}</div>}
|
{(st.warnings || st.alarms) && <div className="col-span-4 text-warning">⚠ {st.warnings} {st.alarms}</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
{st.connected && isACOM && (
|
||||||
);
|
|
||||||
}
|
|
||||||
// Live ACOM amplifier status + OPERATE/STANDBY. Module-scoped for the same
|
|
||||||
// remount reason as SPEStatusCard. Polls once a second while shown.
|
|
||||||
function ACOMStatusCard() {
|
|
||||||
const [st, setSt] = useState<any>({ connected: false });
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true;
|
|
||||||
const tick = () => GetACOMStatus().then((s) => alive && setSt(s || {})).catch(() => {});
|
|
||||||
tick();
|
|
||||||
const id = window.setInterval(tick, 1000);
|
|
||||||
return () => { alive = false; window.clearInterval(id); };
|
|
||||||
}, []);
|
|
||||||
const operate = !!st.operate;
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={cn('size-2 rounded-full', st.connected ? 'bg-success animate-pulse' : 'bg-danger')} />
|
|
||||||
<span className="font-semibold">{st.connected ? `ACOM ${st.model || ''}` : `ACOM ${st.model || ''} — not connected`}</span>
|
|
||||||
{!st.connected && st.last_error && <span className="text-danger truncate text-[10px]">{st.last_error}</span>}
|
|
||||||
<span className="flex-1" />
|
|
||||||
<Button size="sm" variant={operate ? 'default' : 'outline'} disabled={!st.connected}
|
|
||||||
onClick={() => ACOMSetOperate(!operate).catch(() => {})}
|
|
||||||
title="Toggle OPERATE / STANDBY">
|
|
||||||
{operate ? 'OPERATE' : 'STANDBY'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{st.connected && (
|
|
||||||
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
|
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
|
||||||
<div>{st.state}</div>
|
<div>{st.state}</div>
|
||||||
<div>Band {st.band || '—'}</div>
|
<div>Band {st.band || '—'}</div>
|
||||||
@@ -708,6 +688,13 @@ function ACOMStatusCard() {
|
|||||||
{st.err_text && <div className="col-span-4 text-warning">⚠ {st.err_text} ({st.err_code})</div>}
|
{st.err_text && <div className="col-span-4 text-warning">⚠ {st.err_text} ({st.err_code})</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{st.connected && !isSPE && !isACOM && (
|
||||||
|
<div className="grid grid-cols-3 gap-x-3 gap-y-1 font-mono text-[11px]">
|
||||||
|
<div>{st.state || ''}</div>
|
||||||
|
<div>Fan {st.fan_mode || '—'}</div>
|
||||||
|
<div>{st.temperature ? `${Math.round(st.temperature)}°C` : ''}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1076,9 +1063,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
|
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
|
||||||
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
||||||
|
|
||||||
// Amplifier control settings (PowerGenius XL over TCP; SPE Expert over serial/IP).
|
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
|
||||||
const [pgxl, setPgxl] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number }>(
|
// each with its own connection. Saved as a whole via SaveAmplifiers.
|
||||||
{ enabled: false, type: 'pgxl', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200 });
|
const [amps, setAmps] = useState<AmpUI[]>([]);
|
||||||
|
|
||||||
// WinKeyer CW keyer settings + macro editor.
|
// WinKeyer CW keyer settings + macro editor.
|
||||||
type WKMac = { label: string; text: string };
|
type WKMac = { label: string; text: string };
|
||||||
@@ -1087,13 +1074,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 }));
|
||||||
@@ -1149,6 +1136,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||||||
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||||||
|
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
|
||||||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||||
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
||||||
// Password-encryption (secret vault) state.
|
// Password-encryption (secret vault) state.
|
||||||
@@ -1395,7 +1383,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
setRotator(r);
|
setRotator(r);
|
||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setPgxl(await GetPGXLSettings() as any); } catch {}
|
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
||||||
setBackupCfg(b as any);
|
setBackupCfg(b as any);
|
||||||
setQslDefaults(qd as any);
|
setQslDefaults(qd as any);
|
||||||
setExtSvc(es as any);
|
setExtSvc(es as any);
|
||||||
@@ -1435,7 +1423,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
try { setRotator(await GetRotatorSettings() as any); } catch {}
|
try { setRotator(await GetRotatorSettings() as any); } catch {}
|
||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setPgxl(await GetPGXLSettings() as any); } catch {}
|
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
||||||
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
||||||
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
||||||
try { setExtSvc(await GetExternalServices() as any); } catch {}
|
try { setExtSvc(await GetExternalServices() as any); } catch {}
|
||||||
@@ -1604,7 +1592,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
await SaveRotatorSettings(rotator as any);
|
await SaveRotatorSettings(rotator as any);
|
||||||
await SaveUltrabeamSettings(ultrabeam as any);
|
await SaveUltrabeamSettings(ultrabeam as any);
|
||||||
await SaveAntGeniusSettings(antgenius as any);
|
await SaveAntGeniusSettings(antgenius as any);
|
||||||
await SavePGXLSettings(pgxl as any);
|
await SaveAmplifiers(amps as any);
|
||||||
await SaveWinkeyerSettings(wk as any);
|
await SaveWinkeyerSettings(wk as any);
|
||||||
await SaveAudioSettings(audioCfg as any);
|
await SaveAudioSettings(audioCfg as any);
|
||||||
await SaveEmailSettings(emailCfg as any);
|
await SaveEmailSettings(emailCfg as any);
|
||||||
@@ -2709,12 +2697,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PGXLPanelSettings() {
|
function PGXLPanelSettings() {
|
||||||
const isPGXL = pgxl.type === 'pgxl';
|
// The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
|
||||||
const isACOM = (pgxl.type || '').startsWith('acom');
|
// presents it as brand + model.
|
||||||
const isSerial = pgxl.transport === 'serial';
|
|
||||||
// The stored `type` stays the single flat value ("spe13", "acom700", "pgxl" —
|
|
||||||
// binding compatibility); the UI presents it as brand + model.
|
|
||||||
const brand = isPGXL ? 'pgxl' : isACOM ? 'acom' : 'spe';
|
|
||||||
const brandModels: Record<string, { value: string; label: string }[]> = {
|
const brandModels: Record<string, { value: string; label: string }[]> = {
|
||||||
spe: [
|
spe: [
|
||||||
{ value: 'spe13', label: 'Expert 1.3K-FA' },
|
{ value: 'spe13', label: 'Expert 1.3K-FA' },
|
||||||
@@ -2729,123 +2713,150 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
{ value: 'acom2020', label: '2020S' },
|
{ value: 'acom2020', label: '2020S' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
const brandOf = (ty: string) => (!ty || ty === 'pgxl') ? 'pgxl' : ty.startsWith('acom') ? 'acom' : 'spe';
|
||||||
|
const patchAmp = (i: number, patch: Partial<AmpUI>) => setAmps((l) => l.map((a, j) => (j === i ? { ...a, ...patch } : a)));
|
||||||
// Each family has a fixed serial speed: SPE talks 115200, the ACOM S-series is
|
// Each family has a fixed serial speed: SPE talks 115200, the ACOM S-series is
|
||||||
// 9600 8N1 — preset it so switching brand just works. PGXL is TCP-only.
|
// 9600 8N1 — preset it so switching brand just works. PGXL is TCP-only.
|
||||||
const applyType = (v: string) => setPgxl((s) => ({
|
const applyType = (i: number, v: string) => patchAmp(i, {
|
||||||
...s, type: v,
|
type: v,
|
||||||
transport: v === 'pgxl' ? 'tcp' : s.transport,
|
transport: v === 'pgxl' ? 'tcp' : amps[i].transport,
|
||||||
baud: v.startsWith('acom') ? 9600 : v.startsWith('spe') ? 115200 : s.baud,
|
baud: v.startsWith('acom') ? 9600 : v.startsWith('spe') ? 115200 : amps[i].baud,
|
||||||
}));
|
});
|
||||||
|
const addAmp = () => setAmps((l) => [...l, {
|
||||||
|
id: '', name: '', enabled: true, type: 'spe13', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200,
|
||||||
|
}]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader title="Amplifier" />
|
<SectionHeader title="Amplifier" hint={t('amp.hint')} />
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
{amps.length === 0 && (
|
||||||
<Checkbox checked={pgxl.enabled} onCheckedChange={(c) => setPgxl((s) => ({ ...s, enabled: !!c }))} />
|
<p className="text-sm text-muted-foreground">{t('amp.none')}</p>
|
||||||
Enable amplifier control
|
)}
|
||||||
</label>
|
{amps.map((amp, i) => {
|
||||||
|
const brand = brandOf(amp.type);
|
||||||
{/* Connection gets the widest column — "Network (RS232-to-Ethernet)" was
|
const isPGXL = brand === 'pgxl';
|
||||||
truncated with 3 equal columns. */}
|
const isACOM = brand === 'acom';
|
||||||
<div className="grid grid-cols-[1fr_1fr_1.7fr] gap-3">
|
const isSerial = !isPGXL && amp.transport === 'serial';
|
||||||
<div className="space-y-1">
|
return (
|
||||||
<Label>Brand</Label>
|
<div key={amp.id || `new-${i}`} className="rounded-lg border border-border p-3 space-y-3">
|
||||||
<Select value={brand} onValueChange={(b) => {
|
|
||||||
if (b === brand) return;
|
|
||||||
applyType(b === 'pgxl' ? 'pgxl' : brandModels[b][0].value);
|
|
||||||
}}>
|
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="pgxl">4O3A</SelectItem>
|
|
||||||
<SelectItem value="spe">SPE</SelectItem>
|
|
||||||
<SelectItem value="acom">ACOM</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Model</Label>
|
|
||||||
{isPGXL ? (
|
|
||||||
// The PowerGenius is the brand's single model — fixed, no choice.
|
|
||||||
<Select value="pgxl" disabled>
|
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="pgxl">PowerGenius XL</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<Select value={pgxl.type} onValueChange={applyType}>
|
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{brandModels[brand].map((m) => <SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* PowerGenius is TCP-only; SPE and ACOM amps connect over USB serial or
|
|
||||||
an RS232-to-Ethernet bridge, so they offer both. */}
|
|
||||||
{!isPGXL && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Connection</Label>
|
|
||||||
<Select value={pgxl.transport} onValueChange={(v) => setPgxl((s) => ({ ...s, transport: v }))}>
|
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="serial">USB (serial COM)</SelectItem>
|
|
||||||
<SelectItem value="tcp">Network (RS232-to-Ethernet)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isSerial ? (
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<div className="space-y-1 col-span-2">
|
|
||||||
<Label>COM port</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={pgxl.com_port || '_'} onValueChange={(v) => setPgxl((s) => ({ ...s, com_port: v === '_' ? '' : v }))}>
|
<Checkbox checked={amp.enabled} onCheckedChange={(c) => patchAmp(i, { enabled: !!c })} />
|
||||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
<Input className="h-8 flex-1" value={amp.name}
|
||||||
<SelectContent>
|
placeholder={t('amp.namePh')}
|
||||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
onChange={(e) => patchAmp(i, { name: e.target.value })} />
|
||||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
<Button variant="ghost" size="sm" className="h-8 text-danger" title={t('amp.remove')}
|
||||||
</SelectContent>
|
onClick={() => setAmps((l) => l.filter((_, j) => j !== i))}>
|
||||||
</Select>
|
<Trash2 className="size-3.5" />
|
||||||
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
|
||||||
<ArrowDown className="size-3.5 rotate-90" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Baud</Label>
|
|
||||||
<Input type="number" min={1200} value={pgxl.baud}
|
|
||||||
onChange={(e) => setPgxl((s) => ({ ...s, baud: parseInt(e.target.value) || 115200 }))} className="font-mono" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<div className="space-y-1 col-span-2">
|
|
||||||
<Label>Host / IP</Label>
|
|
||||||
<Input value={pgxl.host ?? ''} onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
|
|
||||||
placeholder="192.168.1.70" className="font-mono" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>TCP port</Label>
|
|
||||||
<Input type="number" min={1} max={65535} value={pgxl.port}
|
|
||||||
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))} className="font-mono" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isPGXL && pgxl.enabled && (isACOM ? <ACOMStatusCard /> : <SPEStatusCard />)}
|
{/* Connection gets the widest column — "Network (RS232-to-Ethernet)"
|
||||||
{!isPGXL && !isACOM && (
|
was truncated with 3 equal columns. */}
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<div className="grid grid-cols-[1fr_1fr_1.7fr] gap-3">
|
||||||
SPE control uses the amplifier's proprietary serial protocol (OPERATE toggle + live status). Save to (re)connect. Band / power-level / antenna are still managed on the amp from the transceiver CAT.
|
<div className="space-y-1">
|
||||||
</p>
|
<Label>Brand</Label>
|
||||||
)}
|
<Select value={brand} onValueChange={(b) => {
|
||||||
{isACOM && (
|
if (b === brand) return;
|
||||||
<p className="text-[10px] text-muted-foreground">
|
applyType(i, b === 'pgxl' ? 'pgxl' : brandModels[b][0].value);
|
||||||
ACOM control uses the amplifier's serial protocol (9600 8N1): OPERATE / STANDBY / OFF + live telemetry. Save to (re)connect. Power-ON needs the serial DTR/RTS pins wired in the cable (not available over a network bridge). Band tracking stays on the amp itself.
|
}}>
|
||||||
</p>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
)}
|
<SelectContent>
|
||||||
|
<SelectItem value="pgxl">4O3A</SelectItem>
|
||||||
|
<SelectItem value="spe">SPE</SelectItem>
|
||||||
|
<SelectItem value="acom">ACOM</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Model</Label>
|
||||||
|
{isPGXL ? (
|
||||||
|
// The PowerGenius is the brand's single model — fixed, no choice.
|
||||||
|
<Select value="pgxl" disabled>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pgxl">PowerGenius XL</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Select value={amp.type} onValueChange={(v) => applyType(i, v)}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{brandModels[brand].map((m) => <SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* PowerGenius is TCP-only; SPE and ACOM amps connect over USB serial
|
||||||
|
or an RS232-to-Ethernet bridge, so they offer both. */}
|
||||||
|
{!isPGXL && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Connection</Label>
|
||||||
|
<Select value={amp.transport} onValueChange={(v) => patchAmp(i, { transport: v })}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="serial">USB (serial COM)</SelectItem>
|
||||||
|
<SelectItem value="tcp">Network (RS232-to-Ethernet)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSerial ? (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>COM port</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={amp.com_port || '_'} onValueChange={(v) => patchAmp(i, { com_port: v === '_' ? '' : v })}>
|
||||||
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||||
|
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Baud</Label>
|
||||||
|
<Input type="number" min={1200} value={amp.baud}
|
||||||
|
onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) || 115200 })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Host / IP</Label>
|
||||||
|
<Input value={amp.host ?? ''} onChange={(e) => patchAmp(i, { host: e.target.value })}
|
||||||
|
placeholder="192.168.1.70" className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>TCP port</Label>
|
||||||
|
<Input type="number" min={1} max={65535} value={amp.port}
|
||||||
|
onChange={(e) => patchAmp(i, { port: parseInt(e.target.value) || 9008 })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{amp.enabled && amp.id && <AmpStatusCard id={amp.id} />}
|
||||||
|
{!isPGXL && !isACOM && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
SPE control uses the amplifier's proprietary serial protocol (OPERATE toggle + live status). Save to (re)connect. Band / power-level / antenna are still managed on the amp from the transceiver CAT.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isACOM && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
ACOM control uses the amplifier's serial protocol (9600 8N1): OPERATE / STANDBY / OFF + live telemetry. Save to (re)connect. Power-ON needs the serial DTR/RTS pins wired in the cable (not available over a network bridge). Band tracking stays on the amp itself.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button variant="outline" size="sm" onClick={addAmp}>
|
||||||
|
<Plus className="size-3.5 mr-1" /> {t('amp.add')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -2853,11 +2864,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
|
|
||||||
function RotatorPanel() {
|
function RotatorPanel() {
|
||||||
const isRG = (rotator as any).type === 'rotgenius';
|
const isRG = (rotator as any).type === 'rotgenius';
|
||||||
|
const isARCO = (rotator as any).type === 'arco';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Rotator"
|
title="Rotator"
|
||||||
hint={isRG ? undefined : t('rot.hint')}
|
hint={isRG || isARCO ? undefined : t('rot.hint')}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -2867,14 +2879,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('rot.type')}</Label>
|
<Label>{t('rot.type')}</Label>
|
||||||
{/* Switching to Rotator Genius moves the default port to its native
|
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
|
||||||
9006; back to PstRotator restores 12000. */}
|
(placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
|
||||||
<Select value={(rotator as any).type ?? 'pst'}
|
<Select value={(rotator as any).type ?? 'pst'}
|
||||||
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : 12000 } as any))}>
|
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 } as any))}>
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
||||||
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
||||||
|
<SelectItem value="arco">microHAM ARCO (LAN, GS-232A)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -2891,34 +2904,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* The ARCO is reachable over the LAN (TCP) or its USB virtual COM. */}
|
||||||
|
{isARCO && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Connection</Label>
|
||||||
|
<Select value={(rotator as any).transport ?? 'tcp'}
|
||||||
|
onValueChange={(v) => setRotator((s) => ({ ...s, transport: v } as any))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="tcp">Network (LAN)</SelectItem>
|
||||||
|
<SelectItem value="serial">USB (serial COM)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
{isARCO && (rotator as any).transport === 'serial' ? (
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 max-w-xs">
|
||||||
<Label>Host / IP</Label>
|
<Label>COM port</Label>
|
||||||
<Input
|
<div className="flex items-center gap-2">
|
||||||
value={rotator.host ?? ''}
|
<Select value={(rotator as any).com_port || '_'}
|
||||||
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
|
onValueChange={(v) => setRotator((s) => ({ ...s, com_port: v === '_' ? '' : v } as any))}>
|
||||||
placeholder={isRG ? '192.168.1.60' : '127.0.0.1'}
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
className="font-mono"
|
<SelectContent>
|
||||||
/>
|
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||||
|
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
) : (
|
||||||
<Label>{isRG ? 'TCP port' : 'UDP port'}</Label>
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<Input
|
<div className="space-y-1 col-span-2">
|
||||||
type="number" min={1} max={65535}
|
<Label>Host / IP</Label>
|
||||||
value={rotator.port}
|
<Input
|
||||||
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : 12000) }))}
|
value={rotator.host ?? ''}
|
||||||
className="font-mono"
|
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
|
||||||
/>
|
placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min={1} max={65535}
|
||||||
|
value={rotator.port}
|
||||||
|
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : isARCO ? 4001 : 12000) }))}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
{!isRG && (
|
{!isRG && !isARCO && (
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
||||||
This rotator supports elevation (VHF / satellite)
|
This rotator supports elevation (VHF / satellite)
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
||||||
|
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
||||||
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
||||||
@@ -2976,7 +3023,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Select value={wk.engine} onValueChange={(v) => setWkField({ engine: v })}>
|
<Select value={wk.engine} onValueChange={(v) => setWkField({ 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">WinKeyer (K1EL, serial)</SelectItem>
|
||||||
|
<SelectItem value="serial">Serial port (DTR=CW / RTS=PTT)</SelectItem>
|
||||||
<SelectItem value="icom">Icom CI-V (rig keyer)</SelectItem>
|
<SelectItem value="icom">Icom CI-V (rig keyer)</SelectItem>
|
||||||
<SelectItem value="flex">FlexRadio (CWX)</SelectItem>
|
<SelectItem value="flex">FlexRadio (CWX)</SelectItem>
|
||||||
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
|
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
|
||||||
@@ -3029,6 +3077,65 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : wk.engine === 'serial' ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-muted-foreground -mt-2">
|
||||||
|
OpsLog keys the Morse itself by toggling a serial control line — the "hardware CW keying" a Yaesu <strong>SCU-17</strong> and generic interfaces use (like N1MM: DTR = CW, RTS = PTT). No WinKeyer chip. Put the rig in CW mode and pick the interface's <strong>keying COM port</strong> below (this is the CW/PTT port, <em>not</em> the CAT port). Speed, Farnsworth, lead-in/tail and PTT apply; paddle mode, ratio and sidetone are WinKeyer-only.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Keying COM port</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="— COM port —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||||
|
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 px-2" title="Reload ports"
|
||||||
|
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>CW key line</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">DTR = CW, RTS = PTT</SelectItem>
|
||||||
|
<SelectItem value="rts">RTS = CW, DTR = PTT</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Speed (WPM)</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>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>Lead-in (ms)</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>Tail (ms)</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 })} /> Key PTT line
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={wk.cw_invert} onCheckedChange={(c) => setWkField({ cw_invert: !!c })} />
|
||||||
|
Invert keying (active-LOW) — tick this only if the rig transmits at rest / keys backwards
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
@@ -4583,6 +4690,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
|
||||||
{t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
|
{t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
|
||||||
|
{t('gen.groupDigital')} <span className="text-xs text-muted-foreground">{t('gen.groupDigitalHint')}</span>
|
||||||
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
|
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
|
||||||
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
|
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, 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,11 +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, 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 };
|
||||||
@@ -205,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}
|
||||||
@@ -259,7 +263,26 @@ 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
|
||||||
|
// FlexRadio panel still get the controls. EVERY configured amp gets its own
|
||||||
|
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
|
||||||
|
// 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(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = () => Promise.all([
|
||||||
|
GetAmpStatuses().catch(() => []),
|
||||||
|
GetFlexState().catch(() => null),
|
||||||
|
]).then(([l, fx]: any[]) => { if (alive) { setAmps((l ?? []) as any[]); setFlexState(fx); } });
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 1500);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadDevices = useCallback(async () => {
|
const loadDevices = useCallback(async () => {
|
||||||
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||||||
@@ -346,24 +369,25 @@ 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>
|
||||||
);
|
);
|
||||||
@@ -380,29 +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} /> });
|
||||||
}
|
}
|
||||||
|
// One card per configured amplifier (identical to the Flex panel's card).
|
||||||
|
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;
|
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')}>
|
||||||
@@ -422,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]"
|
||||||
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
|
||||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
|
||||||
onDrop={(e) => { e.preventDefault(); onDrop(w.id); }}
|
<div draggable
|
||||||
className={cn('cursor-grab active:cursor-grabbing', dragId.current === w.id && 'opacity-60')}>
|
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||||
{w.node}
|
onDragEnd={() => { dragId.current = null; }}
|
||||||
|
title={t('station.dragMove')}
|
||||||
|
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',
|
||||||
|
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>
|
||||||
|
|||||||
+24
-18
File diff suppressed because one or more lines are too long
@@ -25,6 +25,7 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
||||||
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
||||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||||
|
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
|
||||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||||
// Cluster filter selections — restored on reopen.
|
// Cluster filter selections — restored on reopen.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.20.7';
|
export const APP_VERSION = '0.20.11';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+44
@@ -36,6 +36,14 @@ export function ActivateProfile(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||||
|
|
||||||
|
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function AmpOperate(arg1:string,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function AmpPower(arg1:string,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function AmpPowerLevel(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
||||||
@@ -210,6 +218,8 @@ export function FlexSetANF(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetANFLevel(arg1:number):Promise<void>;
|
export function FlexSetANFLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetANFT(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAPF(arg1:boolean):Promise<void>;
|
export function FlexSetAPF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAPFLevel(arg1:number):Promise<void>;
|
export function FlexSetAPFLevel(arg1:number):Promise<void>;
|
||||||
@@ -230,10 +240,20 @@ export function FlexSetCWSidetone(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetDAX(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetKeySpeed(arg1:number):Promise<void>;
|
export function FlexSetKeySpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetLMSANF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetLMSANFLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetLMSNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetLMSNRLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMic(arg1:number):Promise<void>;
|
export function FlexSetMic(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMicProfile(arg1:string):Promise<void>;
|
export function FlexSetMicProfile(arg1:string):Promise<void>;
|
||||||
@@ -250,6 +270,10 @@ export function FlexSetNBLevel(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetNR(arg1:boolean):Promise<void>;
|
export function FlexSetNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetNRF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetNRFLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetNRLevel(arg1:number):Promise<void>;
|
export function FlexSetNRLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetPower(arg1:number):Promise<void>;
|
export function FlexSetPower(arg1:number):Promise<void>;
|
||||||
@@ -262,14 +286,22 @@ export function FlexSetRIT(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetRITFreq(arg1:number):Promise<void>;
|
export function FlexSetRITFreq(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetRNN(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetRXAntenna(arg1:string):Promise<void>;
|
export function FlexSetRXAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetSpeexNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetSpeexNRLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetSplit(arg1:boolean):Promise<void>;
|
export function FlexSetSplit(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetTXAntenna(arg1:string):Promise<void>;
|
export function FlexSetTXAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetTXDAX(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>;
|
export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetTXSlice(arg1:number):Promise<void>;
|
export function FlexSetTXSlice(arg1:number):Promise<void>;
|
||||||
@@ -302,6 +334,10 @@ export function GetActiveProfile():Promise<profile.Profile>;
|
|||||||
|
|
||||||
export function GetAlertEmailTo():Promise<string>;
|
export function GetAlertEmailTo():Promise<string>;
|
||||||
|
|
||||||
|
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
|
||||||
|
|
||||||
|
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
|
||||||
|
|
||||||
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
|
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
|
||||||
|
|
||||||
export function GetAntGeniusStatus():Promise<antgenius.Status>;
|
export function GetAntGeniusStatus():Promise<antgenius.Status>;
|
||||||
@@ -630,6 +666,8 @@ export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefau
|
|||||||
|
|
||||||
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function PGXLSetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function PickADIFMonitorFile():Promise<string>;
|
export function PickADIFMonitorFile():Promise<string>;
|
||||||
|
|
||||||
export function PickAudioFolder():Promise<string>;
|
export function PickAudioFolder():Promise<string>;
|
||||||
@@ -724,6 +762,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>;
|
||||||
@@ -736,6 +776,8 @@ export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
|
|||||||
|
|
||||||
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
||||||
|
|
||||||
|
export function SaveAmplifiers(arg1:Array<main.AmpConfig>):Promise<void>;
|
||||||
|
|
||||||
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
||||||
@@ -802,6 +844,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>;
|
||||||
|
|||||||
@@ -26,6 +26,22 @@ export function AddQSO(arg1) {
|
|||||||
return window['go']['main']['App']['AddQSO'](arg1);
|
return window['go']['main']['App']['AddQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AmpFanMode(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['AmpFanMode'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmpOperate(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['AmpOperate'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmpPower(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['AmpPower'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmpPowerLevel(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['AmpPowerLevel'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function AntGeniusActivate(arg1, arg2) {
|
export function AntGeniusActivate(arg1, arg2) {
|
||||||
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
|
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -374,6 +390,10 @@ export function FlexSetANFLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetANFLevel'](arg1);
|
return window['go']['main']['App']['FlexSetANFLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetANFT(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetANFT'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetAPF(arg1) {
|
export function FlexSetAPF(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetAPF'](arg1);
|
return window['go']['main']['App']['FlexSetAPF'](arg1);
|
||||||
}
|
}
|
||||||
@@ -414,6 +434,10 @@ export function FlexSetCWSpeed(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetCWSpeed'](arg1);
|
return window['go']['main']['App']['FlexSetCWSpeed'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetDAX(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetDAX'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetFilter(arg1, arg2) {
|
export function FlexSetFilter(arg1, arg2) {
|
||||||
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
|
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -422,6 +446,22 @@ export function FlexSetKeySpeed(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetKeySpeed'](arg1);
|
return window['go']['main']['App']['FlexSetKeySpeed'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetLMSANF(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetLMSANF'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetLMSANFLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetLMSANFLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetLMSNR(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetLMSNR'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetLMSNRLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetLMSNRLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetMic(arg1) {
|
export function FlexSetMic(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetMic'](arg1);
|
return window['go']['main']['App']['FlexSetMic'](arg1);
|
||||||
}
|
}
|
||||||
@@ -454,6 +494,14 @@ export function FlexSetNR(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetNR'](arg1);
|
return window['go']['main']['App']['FlexSetNR'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetNRF(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetNRF'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetNRFLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetNRFLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetNRLevel(arg1) {
|
export function FlexSetNRLevel(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetNRLevel'](arg1);
|
return window['go']['main']['App']['FlexSetNRLevel'](arg1);
|
||||||
}
|
}
|
||||||
@@ -478,6 +526,10 @@ export function FlexSetRITFreq(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetRITFreq'](arg1);
|
return window['go']['main']['App']['FlexSetRITFreq'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetRNN(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetRNN'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetRXAntenna(arg1) {
|
export function FlexSetRXAntenna(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
|
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
|
||||||
}
|
}
|
||||||
@@ -486,6 +538,14 @@ export function FlexSetSidetoneLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
|
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetSpeexNR(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetSpeexNR'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetSpeexNRLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetSpeexNRLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetSplit(arg1) {
|
export function FlexSetSplit(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetSplit'](arg1);
|
return window['go']['main']['App']['FlexSetSplit'](arg1);
|
||||||
}
|
}
|
||||||
@@ -494,6 +554,10 @@ export function FlexSetTXAntenna(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
|
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetTXDAX(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetTXDAX'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetTXFilter(arg1, arg2) {
|
export function FlexSetTXFilter(arg1, arg2) {
|
||||||
return window['go']['main']['App']['FlexSetTXFilter'](arg1, arg2);
|
return window['go']['main']['App']['FlexSetTXFilter'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -558,6 +622,14 @@ export function GetAlertEmailTo() {
|
|||||||
return window['go']['main']['App']['GetAlertEmailTo']();
|
return window['go']['main']['App']['GetAlertEmailTo']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAmpStatuses() {
|
||||||
|
return window['go']['main']['App']['GetAmpStatuses']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetAmplifiers() {
|
||||||
|
return window['go']['main']['App']['GetAmplifiers']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAntGeniusSettings() {
|
export function GetAntGeniusSettings() {
|
||||||
return window['go']['main']['App']['GetAntGeniusSettings']();
|
return window['go']['main']['App']['GetAntGeniusSettings']();
|
||||||
}
|
}
|
||||||
@@ -1214,6 +1286,10 @@ export function PGXLSetFanMode(arg1) {
|
|||||||
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PGXLSetOperate(arg1) {
|
||||||
|
return window['go']['main']['App']['PGXLSetOperate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function PickADIFMonitorFile() {
|
export function PickADIFMonitorFile() {
|
||||||
return window['go']['main']['App']['PickADIFMonitorFile']();
|
return window['go']['main']['App']['PickADIFMonitorFile']();
|
||||||
}
|
}
|
||||||
@@ -1402,6 +1478,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);
|
||||||
}
|
}
|
||||||
@@ -1426,6 +1506,10 @@ export function SaveAlertRule(arg1) {
|
|||||||
return window['go']['main']['App']['SaveAlertRule'](arg1);
|
return window['go']['main']['App']['SaveAlertRule'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveAmplifiers(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveAmplifiers'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveAntGeniusSettings(arg1) {
|
export function SaveAntGeniusSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveAntGeniusSettings'](arg1);
|
return window['go']['main']['App']['SaveAntGeniusSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1558,6 +1642,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -777,6 +777,19 @@ export namespace cat {
|
|||||||
anf_level: number;
|
anf_level: number;
|
||||||
wnb: boolean;
|
wnb: boolean;
|
||||||
wnb_level: number;
|
wnb_level: number;
|
||||||
|
lms_nr: boolean;
|
||||||
|
lms_nr_level: number;
|
||||||
|
lms_anf: boolean;
|
||||||
|
lms_anf_level: number;
|
||||||
|
speex_nr: boolean;
|
||||||
|
speex_nr_level: number;
|
||||||
|
rnn: boolean;
|
||||||
|
anft: boolean;
|
||||||
|
nrf: boolean;
|
||||||
|
nrf_level: number;
|
||||||
|
dsp_v4: boolean;
|
||||||
|
dax_ch: number;
|
||||||
|
tx_dax: boolean;
|
||||||
rit: boolean;
|
rit: boolean;
|
||||||
rit_freq: number;
|
rit_freq: number;
|
||||||
xit: boolean;
|
xit: boolean;
|
||||||
@@ -844,6 +857,19 @@ export namespace cat {
|
|||||||
this.anf_level = source["anf_level"];
|
this.anf_level = source["anf_level"];
|
||||||
this.wnb = source["wnb"];
|
this.wnb = source["wnb"];
|
||||||
this.wnb_level = source["wnb_level"];
|
this.wnb_level = source["wnb_level"];
|
||||||
|
this.lms_nr = source["lms_nr"];
|
||||||
|
this.lms_nr_level = source["lms_nr_level"];
|
||||||
|
this.lms_anf = source["lms_anf"];
|
||||||
|
this.lms_anf_level = source["lms_anf_level"];
|
||||||
|
this.speex_nr = source["speex_nr"];
|
||||||
|
this.speex_nr_level = source["speex_nr_level"];
|
||||||
|
this.rnn = source["rnn"];
|
||||||
|
this.anft = source["anft"];
|
||||||
|
this.nrf = source["nrf"];
|
||||||
|
this.nrf_level = source["nrf_level"];
|
||||||
|
this.dsp_v4 = source["dsp_v4"];
|
||||||
|
this.dax_ch = source["dax_ch"];
|
||||||
|
this.tx_dax = source["tx_dax"];
|
||||||
this.rit = source["rit"];
|
this.rit = source["rit"];
|
||||||
this.rit_freq = source["rit_freq"];
|
this.rit_freq = source["rit_freq"];
|
||||||
this.xit = source["xit"];
|
this.xit = source["xit"];
|
||||||
@@ -1400,6 +1426,74 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AmpConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
type: string;
|
||||||
|
transport: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
com_port: string;
|
||||||
|
baud: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AmpConfig(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.transport = source["transport"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.port = source["port"];
|
||||||
|
this.com_port = source["com_port"];
|
||||||
|
this.baud = source["baud"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class AmpStatus {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
pgxl?: powergenius.Status;
|
||||||
|
spe?: spe.Status;
|
||||||
|
acom?: acom.Status;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AmpStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.pgxl = this.convertValues(source["pgxl"], powergenius.Status);
|
||||||
|
this.spe = this.convertValues(source["spe"], spe.Status);
|
||||||
|
this.acom = this.convertValues(source["acom"], acom.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
export class AntGeniusSettings {
|
export class AntGeniusSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -2583,6 +2677,8 @@ export namespace main {
|
|||||||
port: number;
|
port: number;
|
||||||
has_elevation: boolean;
|
has_elevation: boolean;
|
||||||
rotator_num: number;
|
rotator_num: number;
|
||||||
|
transport: string;
|
||||||
|
com_port: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new RotatorSettings(source);
|
return new RotatorSettings(source);
|
||||||
@@ -2596,6 +2692,8 @@ export namespace main {
|
|||||||
this.port = source["port"];
|
this.port = source["port"];
|
||||||
this.has_elevation = source["has_elevation"];
|
this.has_elevation = source["has_elevation"];
|
||||||
this.rotator_num = source["rotator_num"];
|
this.rotator_num = source["rotator_num"];
|
||||||
|
this.transport = source["transport"];
|
||||||
|
this.com_port = source["com_port"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class SecretStatus {
|
export class SecretStatus {
|
||||||
@@ -2937,6 +3035,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;
|
||||||
@@ -2963,6 +3064,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"];
|
||||||
@@ -3208,6 +3312,7 @@ export namespace powergenius {
|
|||||||
state?: string;
|
state?: string;
|
||||||
fan_mode?: string;
|
fan_mode?: string;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
|
operate: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Status(source);
|
return new Status(source);
|
||||||
@@ -3221,6 +3326,7 @@ export namespace powergenius {
|
|||||||
this.state = source["state"];
|
this.state = source["state"];
|
||||||
this.fan_mode = source["fan_mode"];
|
this.fan_mode = source["fan_mode"];
|
||||||
this.temperature = source["temperature"];
|
this.temperature = source["temperature"];
|
||||||
|
this.operate = source["operate"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+79
-50
@@ -259,30 +259,30 @@ type FlexSliceInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FlexTXState struct {
|
type FlexTXState struct {
|
||||||
Available bool `json:"available"` // backend is Flex and handshaked
|
Available bool `json:"available"` // backend is Flex and handshaked
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
// Slices lists every in-use receiver slice (A/B/C/D…) so the panel can show
|
// Slices lists every in-use receiver slice (A/B/C/D…) so the panel can show
|
||||||
// them all and highlight the active one. The active slice drives everything.
|
// them all and highlight the active one. The active slice drives everything.
|
||||||
Slices []FlexSliceInfo `json:"slices,omitempty"`
|
Slices []FlexSliceInfo `json:"slices,omitempty"`
|
||||||
RFPower int `json:"rf_power"`
|
RFPower int `json:"rf_power"`
|
||||||
TunePower int `json:"tune_power"`
|
TunePower int `json:"tune_power"`
|
||||||
Tune bool `json:"tune"` // tune carrier active
|
Tune bool `json:"tune"` // tune carrier active
|
||||||
Transmitting bool `json:"transmitting"` // interlock state = TRANSMITTING
|
Transmitting bool `json:"transmitting"` // interlock state = TRANSMITTING
|
||||||
VoxEnable bool `json:"vox_enable"`
|
VoxEnable bool `json:"vox_enable"`
|
||||||
VoxLevel int `json:"vox_level"`
|
VoxLevel int `json:"vox_level"`
|
||||||
VoxDelay int `json:"vox_delay"`
|
VoxDelay int `json:"vox_delay"`
|
||||||
ProcEnable bool `json:"proc_enable"`
|
ProcEnable bool `json:"proc_enable"`
|
||||||
ProcLevel int `json:"proc_level"`
|
ProcLevel int `json:"proc_level"`
|
||||||
Mon bool `json:"mon"`
|
Mon bool `json:"mon"`
|
||||||
MonLevel int `json:"mon_level"`
|
MonLevel int `json:"mon_level"`
|
||||||
MicLevel int `json:"mic_level"`
|
MicLevel int `json:"mic_level"`
|
||||||
TXFilterLow int `json:"tx_filter_low"` // TX filter low cut (Hz)
|
TXFilterLow int `json:"tx_filter_low"` // TX filter low cut (Hz)
|
||||||
TXFilterHigh int `json:"tx_filter_high"` // TX filter high cut (Hz)
|
TXFilterHigh int `json:"tx_filter_high"` // TX filter high cut (Hz)
|
||||||
// Mic profiles (SmartSDR): the list of available profiles + the loaded one.
|
// Mic profiles (SmartSDR): the list of available profiles + the loaded one.
|
||||||
MicProfile string `json:"mic_profile,omitempty"`
|
MicProfile string `json:"mic_profile,omitempty"`
|
||||||
MicProfiles []string `json:"mic_profiles,omitempty"`
|
MicProfiles []string `json:"mic_profiles,omitempty"`
|
||||||
ATUStatus string `json:"atu_status,omitempty"`
|
ATUStatus string `json:"atu_status,omitempty"`
|
||||||
ATUMemories bool `json:"atu_memories"`
|
ATUMemories bool `json:"atu_memories"`
|
||||||
// Active RX slice DSP controls.
|
// Active RX slice DSP controls.
|
||||||
RXAvail bool `json:"rx_avail"` // an RX slice exists
|
RXAvail bool `json:"rx_avail"` // an RX slice exists
|
||||||
Split bool `json:"split"` // RX/TX on separate slices
|
Split bool `json:"split"` // RX/TX on separate slices
|
||||||
@@ -304,6 +304,22 @@ type FlexTXState struct {
|
|||||||
ANFLevel int `json:"anf_level"`
|
ANFLevel int `json:"anf_level"`
|
||||||
WNB bool `json:"wnb"`
|
WNB bool `json:"wnb"`
|
||||||
WNBLevel int `json:"wnb_level"`
|
WNBLevel int `json:"wnb_level"`
|
||||||
|
// SmartSDR v4 DSP (8000/Aurora series): NRL/ANFL (legacy LMS), NRS
|
||||||
|
// (spectral subtraction), RNN (AI NR), ANFT (FFT notch), NRF (NR w/filter).
|
||||||
|
// DSPV4 is true once the radio reported any of these keys — gates the UI.
|
||||||
|
LMSNR bool `json:"lms_nr"`
|
||||||
|
LMSNRLevel int `json:"lms_nr_level"`
|
||||||
|
LMSANF bool `json:"lms_anf"`
|
||||||
|
LMSANFLevel int `json:"lms_anf_level"`
|
||||||
|
SpeexNR bool `json:"speex_nr"`
|
||||||
|
SpeexNRLevel int `json:"speex_nr_level"`
|
||||||
|
RNN bool `json:"rnn"`
|
||||||
|
ANFT bool `json:"anft"`
|
||||||
|
NRF bool `json:"nrf"`
|
||||||
|
NRFLevel int `json:"nrf_level"`
|
||||||
|
DSPV4 bool `json:"dsp_v4"`
|
||||||
|
DAXCh int `json:"dax_ch"` // DAX audio channel of the active slice (0 = off, 1-8)
|
||||||
|
TXDAX bool `json:"tx_dax"` // DAX is the TX audio source (SmartSDR transmit-bar DAX button)
|
||||||
// RIT/XIT — offsets applied to the active slice's RX / TX frequency without
|
// RIT/XIT — offsets applied to the active slice's RX / TX frequency without
|
||||||
// moving the slice. The offset survives the switch being turned off, so
|
// moving the slice. The offset survives the switch being turned off, so
|
||||||
// re-enabling restores it, exactly like the radio's own knob.
|
// re-enabling restores it, exactly like the radio's own knob.
|
||||||
@@ -337,7 +353,7 @@ type FlexMeter struct {
|
|||||||
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
|
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
|
||||||
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
|
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
|
||||||
Unit string `json:"unit,omitempty"`
|
Unit string `json:"unit,omitempty"`
|
||||||
Slice int `json:"slice"` // for SLC meters, the slice index it belongs to; -1 otherwise
|
Slice int `json:"slice"` // for SLC meters, the slice index it belongs to; -1 otherwise
|
||||||
Value float64 `json:"value"`
|
Value float64 `json:"value"`
|
||||||
Lo float64 `json:"lo"`
|
Lo float64 `json:"lo"`
|
||||||
Hi float64 `json:"hi"`
|
Hi float64 `json:"hi"`
|
||||||
@@ -386,6 +402,19 @@ type FlexController interface {
|
|||||||
SetAPFLevel(int) error
|
SetAPFLevel(int) error
|
||||||
SetWNB(bool) error
|
SetWNB(bool) error
|
||||||
SetWNBLevel(int) error
|
SetWNBLevel(int) error
|
||||||
|
// SmartSDR v4 DSP (8000/Aurora): NRL / ANFL / NRS / RNN / ANFT / NRF.
|
||||||
|
SetLMSNR(bool) error
|
||||||
|
SetLMSNRLevel(int) error
|
||||||
|
SetLMSANF(bool) error
|
||||||
|
SetLMSANFLevel(int) error
|
||||||
|
SetSpeexNR(bool) error
|
||||||
|
SetSpeexNRLevel(int) error
|
||||||
|
SetRNN(bool) error
|
||||||
|
SetANFT(bool) error
|
||||||
|
SetNRF(bool) error
|
||||||
|
SetNRFLevel(int) error
|
||||||
|
SetDAX(int) error // slice RX DAX channel 0 (off) - 8
|
||||||
|
SetTXDAX(bool) error // TX audio from DAX (transmit set dax=)
|
||||||
SetRIT(bool) error
|
SetRIT(bool) error
|
||||||
SetRITFreq(int) error
|
SetRITFreq(int) error
|
||||||
SetXIT(bool) error
|
SetXIT(bool) error
|
||||||
@@ -445,7 +474,7 @@ type IcomTXState struct {
|
|||||||
// Transmit + live status (polled).
|
// Transmit + live status (polled).
|
||||||
Transmitting bool `json:"transmitting"`
|
Transmitting bool `json:"transmitting"`
|
||||||
Split bool `json:"split"`
|
Split bool `json:"split"`
|
||||||
SMeter int `json:"s_meter"` // 0-100 (raw 0-255; S9≈120)
|
SMeter int `json:"s_meter"` // 0-100 (raw 0-255; S9≈120)
|
||||||
PowerMeter int `json:"power_meter"` // 0-100 (TX Po)
|
PowerMeter int `json:"power_meter"` // 0-100 (TX Po)
|
||||||
SWRMeter int `json:"swr_meter"` // 0-100 (TX SWR)
|
SWRMeter int `json:"swr_meter"` // 0-100 (TX SWR)
|
||||||
// RIT / ΔTX (XIT).
|
// RIT / ΔTX (XIT).
|
||||||
@@ -456,20 +485,20 @@ type IcomTXState struct {
|
|||||||
KeySpeedWPM int `json:"key_speed_wpm"` // current KEY SPEED in WPM
|
KeySpeedWPM int `json:"key_speed_wpm"` // current KEY SPEED in WPM
|
||||||
BreakIn int `json:"break_in"` // CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
BreakIn int `json:"break_in"` // CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||||
// Set controls.
|
// Set controls.
|
||||||
RFPower int `json:"rf_power"` // 0-100 (TX output)
|
RFPower int `json:"rf_power"` // 0-100 (TX output)
|
||||||
MicGain int `json:"mic_gain"` // 0-100
|
MicGain int `json:"mic_gain"` // 0-100
|
||||||
AFGain int `json:"af_gain"`
|
AFGain int `json:"af_gain"`
|
||||||
RFGain int `json:"rf_gain"`
|
RFGain int `json:"rf_gain"`
|
||||||
NB bool `json:"nb"`
|
NB bool `json:"nb"`
|
||||||
NBLevel int `json:"nb_level"`
|
NBLevel int `json:"nb_level"`
|
||||||
NR bool `json:"nr"`
|
NR bool `json:"nr"`
|
||||||
NRLevel int `json:"nr_level"`
|
NRLevel int `json:"nr_level"`
|
||||||
ANF bool `json:"anf"`
|
ANF bool `json:"anf"`
|
||||||
APF bool `json:"apf"` // audio peak filter (CW only)
|
APF bool `json:"apf"` // audio peak filter (CW only)
|
||||||
AGC string `json:"agc,omitempty"` // FAST | MID | SLOW
|
AGC string `json:"agc,omitempty"` // FAST | MID | SLOW
|
||||||
Preamp int `json:"preamp"` // 0=off, 1=P.AMP1, 2=P.AMP2
|
Preamp int `json:"preamp"` // 0=off, 1=P.AMP1, 2=P.AMP2
|
||||||
Att int `json:"att"` // dB attenuation, 0=off
|
Att int `json:"att"` // dB attenuation, 0=off
|
||||||
Filter int `json:"filter"` // 1 | 2 | 3 (FIL1/2/3)
|
Filter int `json:"filter"` // 1 | 2 | 3 (FIL1/2/3)
|
||||||
// Antenna (IC-7610 = ANT1/ANT2).
|
// Antenna (IC-7610 = ANT1/ANT2).
|
||||||
Antenna int `json:"antenna"` // 1 | 2 (0 = unknown)
|
Antenna int `json:"antenna"` // 1 | 2 (0 = unknown)
|
||||||
// Filter fine controls: Twin PBT + manual notch (0-100, 50 = centre).
|
// Filter fine controls: Twin PBT + manual notch (0-100, 50 = centre).
|
||||||
@@ -510,20 +539,20 @@ type IcomController interface {
|
|||||||
SetMicGain(int) error
|
SetMicGain(int) error
|
||||||
SetIcomSplit(bool) error
|
SetIcomSplit(bool) error
|
||||||
TuneATU() error
|
TuneATU() error
|
||||||
SetScope(bool) error // enable/disable the spectrum-scope waveform stream
|
SetScope(bool) error // enable/disable the spectrum-scope waveform stream
|
||||||
SetScopeMode(bool) error // true = fixed span, false = center-on-VFO
|
SetScopeMode(bool) error // true = fixed span, false = center-on-VFO
|
||||||
SetScopeEdges(int64, int64) error // point the fixed scope at low..high Hz (centre/pan)
|
SetScopeEdges(int64, int64) error // point the fixed scope at low..high Hz (centre/pan)
|
||||||
ScopeData() ScopeSweep // latest assembled sweep (empty until enabled)
|
ScopeData() ScopeSweep // latest assembled sweep (empty until enabled)
|
||||||
SetRIT(int) error // RIT/ΔTX offset in signed Hz
|
SetRIT(int) error // RIT/ΔTX offset in signed Hz
|
||||||
SetRITOn(bool) error // RIT on/off
|
SetRITOn(bool) error // RIT on/off
|
||||||
SetXITOn(bool) error // ΔTX (XIT) on/off
|
SetXITOn(bool) error // ΔTX (XIT) on/off
|
||||||
SendCW(string) error // key a CW message via the rig's keyer (CI-V 0x17)
|
SendCW(string) error // key a CW message via the rig's keyer (CI-V 0x17)
|
||||||
StopCW() error // abort the CW message being sent
|
StopCW() error // abort the CW message being sent
|
||||||
SetKeySpeed(int) error // CW keyer speed in WPM
|
SetKeySpeed(int) error // CW keyer speed in WPM
|
||||||
SetBreakIn(int) error // CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
SetBreakIn(int) error // CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||||
SetAntenna(int) error // 1 = ANT1, 2 = ANT2
|
SetAntenna(int) error // 1 = ANT1, 2 = ANT2
|
||||||
SetPBTInner(int) error // Twin PBT inside (0-100, 50 = centre)
|
SetPBTInner(int) error // Twin PBT inside (0-100, 50 = centre)
|
||||||
SetPBTOuter(int) error // Twin PBT outside (0-100, 50 = centre)
|
SetPBTOuter(int) error // Twin PBT outside (0-100, 50 = centre)
|
||||||
SetManualNotch(bool) error
|
SetManualNotch(bool) error
|
||||||
SetNotchPos(int) error // manual-notch position (0-100, 50 = centre)
|
SetNotchPos(int) error // manual-notch position (0-100, 50 = centre)
|
||||||
SetSquelch(int) error
|
SetSquelch(int) error
|
||||||
|
|||||||
+161
-17
@@ -30,10 +30,10 @@ type Flex struct {
|
|||||||
conn net.Conn
|
conn net.Conn
|
||||||
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop); nil when not dialing
|
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop); nil when not dialing
|
||||||
wmu sync.Mutex // serialises writes to conn
|
wmu sync.Mutex // serialises writes to conn
|
||||||
seq int
|
seq int
|
||||||
handle string
|
handle string
|
||||||
model string
|
model string
|
||||||
gotHandle bool
|
gotHandle bool
|
||||||
|
|
||||||
slices map[int]*flexSlice
|
slices map[int]*flexSlice
|
||||||
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
|
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
|
||||||
@@ -90,12 +90,28 @@ type flexSlice struct {
|
|||||||
apfLevel int
|
apfLevel int
|
||||||
wnb bool // wideband noise blanker
|
wnb bool // wideband noise blanker
|
||||||
wnbLevel int
|
wnbLevel int
|
||||||
rit bool // receive incremental tuning enabled
|
// SmartSDR v4 DSP (8000/Aurora series). Key names per the FlexLib slice
|
||||||
ritFreq int // RIT offset in Hz (negative = down)
|
// docs: lms_nr(NRL) / lms_anf(ANFL) / speex_nr(NRS) / rnnoise(RNN) /
|
||||||
xit bool // transmit incremental tuning enabled
|
// anft(ANFT) / nrf(NRF). Older radios never report them — dspV4 flips true
|
||||||
xitFreq int // XIT offset in Hz
|
// the first time any of these keys appears, and gates the extra UI rows.
|
||||||
filterLo int // slice filter low cut (Hz)
|
lmsNR bool // NRL — legacy LMS noise reduction
|
||||||
filterHi int // slice filter high cut (Hz)
|
lmsNRLevel int
|
||||||
|
lmsANF bool // ANFL — legacy LMS auto-notch
|
||||||
|
lmsANFLevel int
|
||||||
|
speexNR bool // NRS — spectral subtraction NR
|
||||||
|
speexNRLevel int
|
||||||
|
rnnoise bool // RNN — AI noise reduction (on/off only)
|
||||||
|
anft bool // ANFT — FFT-based auto-notch (on/off only)
|
||||||
|
nrf bool // NRF — noise reduction w/ filter
|
||||||
|
nrfLevel int
|
||||||
|
dspV4 bool
|
||||||
|
daxCh int // DAX audio channel for this slice (0 = off, 1-8)
|
||||||
|
rit bool // receive incremental tuning enabled
|
||||||
|
ritFreq int // RIT offset in Hz (negative = down)
|
||||||
|
xit bool // transmit incremental tuning enabled
|
||||||
|
xitFreq int // XIT offset in Hz
|
||||||
|
filterLo int // slice filter low cut (Hz)
|
||||||
|
filterHi int // slice filter high cut (Hz)
|
||||||
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
|
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
|
||||||
txAnt string // selected TX antenna
|
txAnt string // selected TX antenna
|
||||||
antList []string // antennas valid for this slice (RX side)
|
antList []string // antennas valid for this slice (RX side)
|
||||||
@@ -118,8 +134,9 @@ type flexTX struct {
|
|||||||
mon bool
|
mon bool
|
||||||
monLevel int
|
monLevel int
|
||||||
micLevel int
|
micLevel int
|
||||||
filterLow int // TX filter low cut (Hz)
|
filterLow int // TX filter low cut (Hz)
|
||||||
filterHigh int // TX filter high cut (Hz)
|
filterHigh int // TX filter high cut (Hz)
|
||||||
|
dax bool // TX audio comes from DAX (SmartSDR's transmit-bar DAX button)
|
||||||
atuStatus string
|
atuStatus string
|
||||||
atuMemories bool
|
atuMemories bool
|
||||||
// CW keyer params (set via the top-level "cw" commands).
|
// CW keyer params (set via the top-level "cw" commands).
|
||||||
@@ -431,6 +448,8 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
f.tx.tunePower = atoiDefault(val, f.tx.tunePower)
|
f.tx.tunePower = atoiDefault(val, f.tx.tunePower)
|
||||||
case "tune":
|
case "tune":
|
||||||
f.tx.tune = val == "1"
|
f.tx.tune = val == "1"
|
||||||
|
case "dax":
|
||||||
|
f.tx.dax = val == "1"
|
||||||
case "vox_enable":
|
case "vox_enable":
|
||||||
f.tx.voxEnable = val == "1"
|
f.tx.voxEnable = val == "1"
|
||||||
case "vox_level":
|
case "vox_level":
|
||||||
@@ -630,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()
|
||||||
}
|
}
|
||||||
@@ -641,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:] {
|
||||||
@@ -809,6 +860,28 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
s.wnb = val == "1"
|
s.wnb = val == "1"
|
||||||
case "wnb_level":
|
case "wnb_level":
|
||||||
s.wnbLevel = atoiDefault(val, s.wnbLevel)
|
s.wnbLevel = atoiDefault(val, s.wnbLevel)
|
||||||
|
case "lms_nr":
|
||||||
|
s.lmsNR, s.dspV4 = val == "1", true
|
||||||
|
case "lms_nr_level":
|
||||||
|
s.lmsNRLevel, s.dspV4 = atoiDefault(val, s.lmsNRLevel), true
|
||||||
|
case "lms_anf":
|
||||||
|
s.lmsANF, s.dspV4 = val == "1", true
|
||||||
|
case "lms_anf_level":
|
||||||
|
s.lmsANFLevel, s.dspV4 = atoiDefault(val, s.lmsANFLevel), true
|
||||||
|
case "speex_nr":
|
||||||
|
s.speexNR, s.dspV4 = val == "1", true
|
||||||
|
case "speex_nr_level":
|
||||||
|
s.speexNRLevel, s.dspV4 = atoiDefault(val, s.speexNRLevel), true
|
||||||
|
case "rnnoise":
|
||||||
|
s.rnnoise, s.dspV4 = val == "1", true
|
||||||
|
case "anft":
|
||||||
|
s.anft, s.dspV4 = val == "1", true
|
||||||
|
case "nrf":
|
||||||
|
s.nrf, s.dspV4 = val == "1", true
|
||||||
|
case "nrf_level":
|
||||||
|
s.nrfLevel, s.dspV4 = atoiDefault(val, s.nrfLevel), true
|
||||||
|
case "dax":
|
||||||
|
s.daxCh = atoiDefault(val, s.daxCh)
|
||||||
case "rit_on":
|
case "rit_on":
|
||||||
s.rit = val == "1"
|
s.rit = val == "1"
|
||||||
case "rit_freq":
|
case "rit_freq":
|
||||||
@@ -1121,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 != "" {
|
||||||
@@ -1274,6 +1351,7 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
MicLevel: f.tx.micLevel,
|
MicLevel: f.tx.micLevel,
|
||||||
TXFilterLow: f.tx.filterLow,
|
TXFilterLow: f.tx.filterLow,
|
||||||
TXFilterHigh: f.tx.filterHigh,
|
TXFilterHigh: f.tx.filterHigh,
|
||||||
|
TXDAX: f.tx.dax,
|
||||||
MicProfile: f.micProfile,
|
MicProfile: f.micProfile,
|
||||||
MicProfiles: f.micProfiles,
|
MicProfiles: f.micProfiles,
|
||||||
ATUStatus: f.tx.atuStatus,
|
ATUStatus: f.tx.atuStatus,
|
||||||
@@ -1328,6 +1406,18 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
st.APFLevel = rx.apfLevel
|
st.APFLevel = rx.apfLevel
|
||||||
st.WNB = rx.wnb
|
st.WNB = rx.wnb
|
||||||
st.WNBLevel = rx.wnbLevel
|
st.WNBLevel = rx.wnbLevel
|
||||||
|
st.LMSNR = rx.lmsNR
|
||||||
|
st.LMSNRLevel = rx.lmsNRLevel
|
||||||
|
st.LMSANF = rx.lmsANF
|
||||||
|
st.LMSANFLevel = rx.lmsANFLevel
|
||||||
|
st.SpeexNR = rx.speexNR
|
||||||
|
st.SpeexNRLevel = rx.speexNRLevel
|
||||||
|
st.RNN = rx.rnnoise
|
||||||
|
st.ANFT = rx.anft
|
||||||
|
st.NRF = rx.nrf
|
||||||
|
st.NRFLevel = rx.nrfLevel
|
||||||
|
st.DSPV4 = rx.dspV4
|
||||||
|
st.DAXCh = rx.daxCh
|
||||||
st.RIT = rx.rit
|
st.RIT = rx.rit
|
||||||
st.RITFreq = rx.ritFreq
|
st.RITFreq = rx.ritFreq
|
||||||
st.XIT = rx.xit
|
st.XIT = rx.xit
|
||||||
@@ -1396,6 +1486,28 @@ func (f *Flex) sendSlice(param string, val any) error {
|
|||||||
rx.apf = val == "1"
|
rx.apf = val == "1"
|
||||||
case "apf_level":
|
case "apf_level":
|
||||||
rx.apfLevel = toInt(val)
|
rx.apfLevel = toInt(val)
|
||||||
|
case "lms_nr":
|
||||||
|
rx.lmsNR = val == "1"
|
||||||
|
case "lms_nr_level":
|
||||||
|
rx.lmsNRLevel = toInt(val)
|
||||||
|
case "lms_anf":
|
||||||
|
rx.lmsANF = val == "1"
|
||||||
|
case "lms_anf_level":
|
||||||
|
rx.lmsANFLevel = toInt(val)
|
||||||
|
case "speex_nr":
|
||||||
|
rx.speexNR = val == "1"
|
||||||
|
case "speex_nr_level":
|
||||||
|
rx.speexNRLevel = toInt(val)
|
||||||
|
case "rnnoise":
|
||||||
|
rx.rnnoise = val == "1"
|
||||||
|
case "anft":
|
||||||
|
rx.anft = val == "1"
|
||||||
|
case "nrf":
|
||||||
|
rx.nrf = val == "1"
|
||||||
|
case "nrf_level":
|
||||||
|
rx.nrfLevel = toInt(val)
|
||||||
|
case "dax":
|
||||||
|
rx.daxCh = toInt(val)
|
||||||
case "rxant":
|
case "rxant":
|
||||||
rx.rxAnt = fmt.Sprint(val)
|
rx.rxAnt = fmt.Sprint(val)
|
||||||
case "txant":
|
case "txant":
|
||||||
@@ -1515,14 +1627,15 @@ func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on))
|
|||||||
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
|
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
|
||||||
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
|
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
|
||||||
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
|
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
|
||||||
|
|
||||||
// RIT/XIT — an offset applied to the RX (RIT) or TX (XIT) frequency of the active
|
// RIT/XIT — an offset applied to the RX (RIT) or TX (XIT) frequency of the active
|
||||||
// slice, without moving the slice itself. SmartSDR keeps the offset even while the
|
// slice, without moving the slice itself. SmartSDR keeps the offset even while the
|
||||||
// switch is off, so turning RIT back on restores the last offset — same as the
|
// switch is off, so turning RIT back on restores the last offset — same as the
|
||||||
// radio's own knob.
|
// radio's own knob.
|
||||||
func (f *Flex) SetRIT(on bool) error { return f.sendSlice("rit_on", boolFlex(on)) }
|
func (f *Flex) SetRIT(on bool) error { return f.sendSlice("rit_on", boolFlex(on)) }
|
||||||
func (f *Flex) SetRITFreq(hz int) error { return f.sendSlice("rit_freq", clampOffset(hz)) }
|
func (f *Flex) SetRITFreq(hz int) error { return f.sendSlice("rit_freq", clampOffset(hz)) }
|
||||||
func (f *Flex) SetXIT(on bool) error { return f.sendSlice("xit_on", boolFlex(on)) }
|
func (f *Flex) SetXIT(on bool) error { return f.sendSlice("xit_on", boolFlex(on)) }
|
||||||
func (f *Flex) SetXITFreq(hz int) error { return f.sendSlice("xit_freq", clampOffset(hz)) }
|
func (f *Flex) SetXITFreq(hz int) error { return f.sendSlice("xit_freq", clampOffset(hz)) }
|
||||||
|
|
||||||
// clampOffset keeps a RIT/XIT offset inside what SmartSDR accepts (±99 999 Hz).
|
// clampOffset keeps a RIT/XIT offset inside what SmartSDR accepts (±99 999 Hz).
|
||||||
func clampOffset(hz int) int {
|
func clampOffset(hz int) int {
|
||||||
@@ -1537,6 +1650,37 @@ func clampOffset(hz int) int {
|
|||||||
|
|
||||||
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
|
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
|
||||||
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
|
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
|
||||||
|
|
||||||
|
// SmartSDR v4 DSP (8000/Aurora series) — the extra noise tools SmartSDR ships
|
||||||
|
// beyond nb/nr/anf/wnb. Key names per the FlexLib slice command docs.
|
||||||
|
func (f *Flex) SetLMSNR(on bool) error { return f.sendSlice("lms_nr", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetLMSNRLevel(l int) error { return f.sendSlice("lms_nr_level", clampLevel(l)) }
|
||||||
|
func (f *Flex) SetLMSANF(on bool) error { return f.sendSlice("lms_anf", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetLMSANFLevel(l int) error { return f.sendSlice("lms_anf_level", clampLevel(l)) }
|
||||||
|
func (f *Flex) SetSpeexNR(on bool) error { return f.sendSlice("speex_nr", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetSpeexNRLevel(l int) error { return f.sendSlice("speex_nr_level", clampLevel(l)) }
|
||||||
|
func (f *Flex) SetRNN(on bool) error { return f.sendSlice("rnnoise", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetANFT(on bool) error { return f.sendSlice("anft", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetNRF(on bool) error { return f.sendSlice("nrf", boolFlex(on)) }
|
||||||
|
func (f *Flex) SetNRFLevel(l int) error { return f.sendSlice("nrf_level", clampLevel(l)) }
|
||||||
|
|
||||||
|
// SetDAX routes the active slice's RX audio to a DAX channel (0 = off, 1-8) —
|
||||||
|
// the SmartSDR slice "DAX" selector, e.g. to feed WSJT-X via DAX audio.
|
||||||
|
func (f *Flex) SetDAX(ch int) error {
|
||||||
|
if ch < 0 {
|
||||||
|
ch = 0
|
||||||
|
}
|
||||||
|
if ch > 8 {
|
||||||
|
ch = 8
|
||||||
|
}
|
||||||
|
return f.sendSlice("dax", ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTXDAX toggles DAX as the TRANSMIT audio source — SmartSDR's transmit-bar
|
||||||
|
// DAX button ("transmit set dax="), the one used to send WSJT-X audio.
|
||||||
|
func (f *Flex) SetTXDAX(on bool) error {
|
||||||
|
return f.txSet("transmit set dax="+boolFlex(on), "dax", func(t *flexTX) { t.dax = on })
|
||||||
|
}
|
||||||
func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) }
|
func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) }
|
||||||
func (f *Flex) SetWNBLevel(l int) error { return f.sendSlice("wnb_level", clampLevel(l)) }
|
func (f *Flex) SetWNBLevel(l int) error { return f.sendSlice("wnb_level", clampLevel(l)) }
|
||||||
|
|
||||||
|
|||||||
+50
-18
@@ -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
|
// Waveform data OUTPUT over CI-V: enabled with the scope, and — crucially —
|
||||||
// continue rather than abort the second command.
|
// the ONLY thing we switch off on disable, so the radio's own scope display is
|
||||||
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil {
|
// left exactly as the operator had it.
|
||||||
applog.Printf("icom scope: display on=%v ack: %v", on, err)
|
|
||||||
}
|
|
||||||
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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 {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -33,6 +35,7 @@ type Status struct {
|
|||||||
State string `json:"state,omitempty"` // IDLE / TRANSMIT_A …
|
State string `json:"state,omitempty"` // IDLE / TRANSMIT_A …
|
||||||
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
|
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
|
||||||
Temperature float64 `json:"temperature"`
|
Temperature float64 `json:"temperature"`
|
||||||
|
Operate bool `json:"operate"` // OPERATE vs STANDBY (optimistic until the amp reports it)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -45,6 +48,7 @@ type Client struct {
|
|||||||
|
|
||||||
statusMu sync.RWMutex
|
statusMu sync.RWMutex
|
||||||
status Status
|
status Status
|
||||||
|
lastRaw string // last raw status payload — logged on change so unknown fields (operate?) can be mapped from a real amp
|
||||||
// Optimistic fan mode kept until the amp's status poll confirms it (or it
|
// Optimistic fan mode kept until the amp's status poll confirms it (or it
|
||||||
// ages out) — otherwise a stale poll right after a change reverts the UI.
|
// ages out) — otherwise a stale poll right after a change reverts the UI.
|
||||||
fanPending string
|
fanPending string
|
||||||
@@ -119,8 +123,15 @@ func (c *Client) SetOperate(on bool) error {
|
|||||||
if on {
|
if on {
|
||||||
v = "1"
|
v = "1"
|
||||||
}
|
}
|
||||||
_, err := c.command("operate=" + v)
|
if _, err := c.command("operate=" + v); err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
// Optimistic: the status poll's "operate" field (when the firmware reports
|
||||||
|
// one) confirms or corrects this.
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.Operate = on
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) pollLoop() {
|
func (c *Client) pollLoop() {
|
||||||
@@ -215,6 +226,12 @@ func (c *Client) parse(resp string) {
|
|||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.status.Connected = true
|
c.status.Connected = true
|
||||||
c.status.LastError = ""
|
c.status.LastError = ""
|
||||||
|
// Log each DISTINCT status payload once: the PGXL's field set isn't fully
|
||||||
|
// documented, so this is how we learn the real key for e.g. operate/standby.
|
||||||
|
if data != c.lastRaw {
|
||||||
|
c.lastRaw = data
|
||||||
|
applog.Printf("pgxl: status raw=%q", data)
|
||||||
|
}
|
||||||
for _, pair := range strings.Fields(data) {
|
for _, pair := range strings.Fields(data) {
|
||||||
kv := strings.SplitN(pair, "=", 2)
|
kv := strings.SplitN(pair, "=", 2)
|
||||||
if len(kv) != 2 {
|
if len(kv) != 2 {
|
||||||
@@ -223,6 +240,8 @@ func (c *Client) parse(resp string) {
|
|||||||
switch kv[0] {
|
switch kv[0] {
|
||||||
case "state":
|
case "state":
|
||||||
c.status.State = kv[1]
|
c.status.State = kv[1]
|
||||||
|
case "operate":
|
||||||
|
c.status.Operate = kv[1] == "1"
|
||||||
case "fanmode":
|
case "fanmode":
|
||||||
dev := strings.ToUpper(kv[1])
|
dev := strings.ToUpper(kv[1])
|
||||||
// Honour a recent optimistic change until the amp confirms it.
|
// Honour a recent optimistic change until the amp confirms it.
|
||||||
|
|||||||
+44
-1
@@ -792,6 +792,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
|
||||||
@@ -1371,6 +1396,17 @@ func modeClass(mode string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GroupDigitalMode collapses every digital mode into the single "DIG" bucket
|
||||||
|
// (FT8, FT4, RTTY, PSK… all become one mode) while leaving phone modes and CW
|
||||||
|
// untouched. Used as the optional slot normaliser when the operator prefers
|
||||||
|
// DXCC-style mode classes over per-mode slots (Settings → General).
|
||||||
|
func GroupDigitalMode(mode string) string {
|
||||||
|
if modeClass(mode) == "DIG" {
|
||||||
|
return "DIG"
|
||||||
|
}
|
||||||
|
return strings.ToUpper(mode)
|
||||||
|
}
|
||||||
|
|
||||||
// BandMode is a (band, mode) pair used for the NEW SLOT check.
|
// BandMode is a (band, mode) pair used for the NEW SLOT check.
|
||||||
type BandMode struct {
|
type BandMode struct {
|
||||||
Band string `json:"band"`
|
Band string `json:"band"`
|
||||||
@@ -1802,7 +1838,11 @@ type EntitySlot struct {
|
|||||||
// 0 (unresolvable) skips the QSO.
|
// 0 (unresolvable) skips the QSO.
|
||||||
//
|
//
|
||||||
// One DB scan regardless of input size. Cheap to call per cluster batch.
|
// One DB scan regardless of input size. Cheap to call per cluster batch.
|
||||||
func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int) (map[int]*EntitySlot, error) {
|
//
|
||||||
|
// normMode (nil = identity) maps each QSO's mode before it is stored as a
|
||||||
|
// Modes/Slots key — pass GroupDigitalMode to collapse all digital modes into
|
||||||
|
// one bucket. Callers must normalise their lookup mode the same way.
|
||||||
|
func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int, normMode func(string) string) (map[int]*EntitySlot, error) {
|
||||||
rows, err := r.db.QueryContext(ctx,
|
rows, err := r.db.QueryContext(ctx,
|
||||||
`SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
|
`SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
|
||||||
WHERE band IS NOT NULL AND band != ''
|
WHERE band IS NOT NULL AND band != ''
|
||||||
@@ -1827,6 +1867,9 @@ func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, store
|
|||||||
if key == 0 {
|
if key == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if normMode != nil {
|
||||||
|
mode = normMode(mode)
|
||||||
|
}
|
||||||
e, ok := out[key]
|
e, ok := out[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
e = &EntitySlot{
|
e = &EntitySlot{
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// Package gs232 drives rotator controllers that speak the Yaesu GS-232A
|
||||||
|
// protocol, over a raw TCP socket or a serial COM port. The target device is
|
||||||
|
// the microHAM ARCO: both its LAN "CONTROL PROTOCOL" setting (a TCP port) and
|
||||||
|
// its USB port ("USB CONTROL PROTOCOL", a virtual COM where the baud rate is
|
||||||
|
// irrelevant) can be set to speak Yaesu GS-232A — so OpsLog controls it
|
||||||
|
// directly, no PstRotator in between. ARCO accepts up to four parallel LAN
|
||||||
|
// connections, and commands are single CR-terminated lines, so short
|
||||||
|
// per-call connections (same idiom as the other rotator backends) work fine.
|
||||||
|
//
|
||||||
|
// GS-232A subset used:
|
||||||
|
//
|
||||||
|
// Maaa<CR> move to azimuth aaa (000-450)
|
||||||
|
// S<CR> stop rotation
|
||||||
|
// C<CR> query azimuth — replies "+0aaa" (GS-232A) or "AZ=aaa" (GS-232B
|
||||||
|
// flavour); both are parsed.
|
||||||
|
package gs232
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dialTimeout = 3 * time.Second
|
||||||
|
ioTimeout = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is a stateless per-call sender, mirroring the pst/rotgenius idiom.
|
||||||
|
// Exactly one of (Host, Port) or ComPort is used, per Transport.
|
||||||
|
type Client struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
ComPort string // serial transport: "COM5" etc. (ARCO USB virtual COM — any baud)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a TCP Client with sane defaults applied for empty fields. There
|
||||||
|
// is no standard port: the number is whatever the user typed into the ARCO's
|
||||||
|
// LAN CONTROL PROTOCOL setting — 4001 is only a placeholder.
|
||||||
|
func New(host string, port int) *Client {
|
||||||
|
if host == "" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
if port <= 0 || port > 65535 {
|
||||||
|
port = 4001
|
||||||
|
}
|
||||||
|
return &Client{Host: host, Port: port}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSerial returns a Client talking over the ARCO's USB virtual COM port. The
|
||||||
|
// baud rate is irrelevant on USB per the ARCO manual (8N1 framing matters); we
|
||||||
|
// open at 9600 which also suits a real RS-232 hookup left at its default.
|
||||||
|
func NewSerial(comPort string) *Client {
|
||||||
|
return &Client{ComPort: comPort}
|
||||||
|
}
|
||||||
|
|
||||||
|
// roundTrip opens a connection (TCP or serial per the client's config), sends
|
||||||
|
// one CR-terminated command and (when wantReply) reads one CR/LF-terminated
|
||||||
|
// reply line.
|
||||||
|
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
||||||
|
var conn io.ReadWriteCloser
|
||||||
|
if c.ComPort != "" {
|
||||||
|
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: 9600})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("open ARCO %s: %w", c.ComPort, err)
|
||||||
|
}
|
||||||
|
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
||||||
|
conn = sp
|
||||||
|
} else {
|
||||||
|
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("connect ARCO %s:%d: %w", c.Host, c.Port, err)
|
||||||
|
}
|
||||||
|
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
|
||||||
|
conn = nc
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if _, err := conn.Write([]byte(cmd + "\r")); err != nil {
|
||||||
|
return "", fmt.Errorf("send %q: %w", cmd, err)
|
||||||
|
}
|
||||||
|
if !wantReply {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
buf := make([]byte, 64)
|
||||||
|
var sb strings.Builder
|
||||||
|
deadline := time.Now().Add(ioTimeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
sb.Write(buf[:n])
|
||||||
|
if strings.ContainsAny(sb.String(), "\r\n") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A serial read that times out returns (0, nil) — keep polling until the
|
||||||
|
// overall deadline; a real error ends the read.
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line := strings.TrimSpace(sb.String())
|
||||||
|
if line == "" {
|
||||||
|
return "", fmt.Errorf("no reply to %q", cmd)
|
||||||
|
}
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoTo points the antenna at the given azimuth (0-359). GS-232A takes M000-M450
|
||||||
|
// (overlap rotators accept >360); we normalise to [0,360).
|
||||||
|
func (c *Client) GoTo(az int) error {
|
||||||
|
az = ((az % 360) + 360) % 360
|
||||||
|
_, err := c.roundTrip(fmt.Sprintf("M%03d", az), false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop interrupts any in-progress rotation.
|
||||||
|
func (c *Client) Stop() error {
|
||||||
|
_, err := c.roundTrip("S", false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// azRe matches both reply flavours: "+0aaa" (GS-232A) and "AZ=aaa" (GS-232B).
|
||||||
|
var azRe = regexp.MustCompile(`(?:\+0|AZ=)(\d{3})`)
|
||||||
|
|
||||||
|
// Heading queries the current azimuth. Returns the raw reply for diagnostics.
|
||||||
|
func (c *Client) Heading() (az int, raw string, err error) {
|
||||||
|
raw, err = c.roundTrip("C", true)
|
||||||
|
if err != nil {
|
||||||
|
return 0, raw, err
|
||||||
|
}
|
||||||
|
m := azRe.FindStringSubmatch(raw)
|
||||||
|
if m == nil {
|
||||||
|
return 0, raw, fmt.Errorf("unrecognised azimuth reply %q", raw)
|
||||||
|
}
|
||||||
|
az, _ = strconv.Atoi(m[1])
|
||||||
|
return az % 360, raw, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
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"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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().
|
||||||
|
type serialKeyer struct {
|
||||||
|
port serial.Port
|
||||||
|
keyDTR bool // true: CW on DTR, PTT on RTS (N1MM default). false: swapped.
|
||||||
|
invert bool // true: active-LOW — asserting a line means driving it LOW
|
||||||
|
usePTT bool
|
||||||
|
leadMs int
|
||||||
|
tailMs int
|
||||||
|
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(port serial.Port, cfg Config, onBusy func(bool)) *serialKeyer {
|
||||||
|
k := &serialKeyer{
|
||||||
|
port: port,
|
||||||
|
keyDTR: !strings.EqualFold(strings.TrimSpace(cfg.CWKeyLine), "rts"), // default DTR=CW
|
||||||
|
invert: cfg.CWInvert,
|
||||||
|
usePTT: cfg.UsePTT,
|
||||||
|
leadMs: cfg.LeadInMs,
|
||||||
|
tailMs: cfg.TailMs,
|
||||||
|
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.idle() // start inactive: key up, PTT off
|
||||||
|
go k.run()
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }
|
||||||
|
|
||||||
|
// setKey raises/lowers the CW key line; setPTT the PTT line (only when enabled).
|
||||||
|
func (k *serialKeyer) setKey(down bool) {
|
||||||
|
if k.keyDTR {
|
||||||
|
_ = k.port.SetDTR(k.level(down))
|
||||||
|
} else {
|
||||||
|
_ = k.port.SetRTS(k.level(down))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (k *serialKeyer) setPTT(on bool) {
|
||||||
|
if !k.usePTT {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if k.keyDTR {
|
||||||
|
_ = k.port.SetRTS(k.level(on))
|
||||||
|
} else {
|
||||||
|
_ = k.port.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.port.SetDTR(k.level(false))
|
||||||
|
_ = k.port.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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *serialKeyer) run() {
|
||||||
|
defer close(k.done)
|
||||||
|
defer k.idle()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-k.stop:
|
||||||
|
return
|
||||||
|
case s := <-k.in:
|
||||||
|
k.onBusy(true)
|
||||||
|
k.setPTT(true)
|
||||||
|
k.sleep(time.Duration(k.leadMs) * time.Millisecond)
|
||||||
|
k.keyText(s)
|
||||||
|
hang := time.Duration(k.tailMs) * 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,47 @@ func (m *Manager) Connect(cfg Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
p, err := serial.Open(cfg.Port, &serial.Mode{
|
||||||
|
BaudRate: cfg.Baud,
|
||||||
|
DataBits: 8,
|
||||||
|
Parity: serial.NoParity,
|
||||||
|
StopBits: serial.OneStopBit,
|
||||||
|
})
|
||||||
|
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(p, 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 = p
|
||||||
|
m.serial = sk
|
||||||
|
m.cfg = cfg
|
||||||
|
m.status = Status{Connected: true, WPM: cfg.WPM, Port: cfg.Port}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
line := "DTR (CW) / RTS (PTT)"
|
||||||
|
if strings.EqualFold(cfg.CWKeyLine, "rts") {
|
||||||
|
line = "RTS (CW) / DTR (PTT)"
|
||||||
|
}
|
||||||
|
applog.Printf("winkeyer: serial CW keyer on %s — %s, PTT=%v", cfg.Port, line, 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 +311,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 +343,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 +394,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()
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.20.7"
|
appVersion = "0.20.11"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user