Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f665d666d6 | ||
|
|
5f9ee9efa2 | ||
|
|
44d276c841 | ||
|
|
e656c79589 | ||
|
|
86cbe94cae | ||
|
|
b9b005ea36 | ||
|
|
2c47c5d052 | ||
|
|
446888a330 | ||
|
|
128364260b | ||
|
|
daf38554a5 | ||
|
|
39f095ae75 | ||
|
|
ea8e954eef | ||
|
|
6db90abcad | ||
|
|
6b9307b427 | ||
|
|
ab06673854 | ||
|
|
1cadefd207 | ||
|
|
05fbb8a3bc | ||
|
|
3d88d8f50f | ||
|
|
5b37397a64 | ||
|
|
1184a675c2 | ||
|
|
7a24282aa4 | ||
|
|
701e8a2c25 | ||
|
|
c4ab935d5f | ||
|
|
be00f6a46d | ||
|
|
7b0a1ac832 | ||
|
|
06183bd5d4 | ||
|
|
fafa0c22ab | ||
|
|
4f32012930 | ||
|
|
a8b3269b1e | ||
|
|
25a53e4110 | ||
|
|
8cf53a0aa2 | ||
|
|
e112de5967 |
+300
@@ -0,0 +1,300 @@
|
|||||||
|
# OpsLog
|
||||||
|
|
||||||
|
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie façon
|
||||||
|
Log4OM, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
|
||||||
|
**Icom CI-V** natif (USB **et** à distance par internet, en remplacement de
|
||||||
|
RS-BA1) et **TCI** (SunSDR / Expert Electronics), cluster DX avec alertes de
|
||||||
|
spots, suivi des diplômes, cartes, log de concours, gestion des QSL et un
|
||||||
|
concepteur de cartes QSL. Construit avec **Wails v2** (backend Go + frontend
|
||||||
|
React/TypeScript), **100 % Go** (sans CGO) : SQLite pour la configuration,
|
||||||
|
**MySQL partagé** optionnel pour le journal afin que plusieurs opérateurs
|
||||||
|
partagent un même log. Entièrement thémable et bilingue (anglais / français).
|
||||||
|
|
||||||
|
Développé par **F4BPO**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compilation / développement
|
||||||
|
|
||||||
|
- **Dev :** `wails dev` (rechargement à chaud Vite ; méthodes Go accessibles sur http://localhost:34115).
|
||||||
|
- **Build :** `wails build` (utiliser la version wails v2.11 du projet — `~/go/bin/wails.exe`).
|
||||||
|
- **Régénérer les bindings Go↔TS** après modification des méthodes `App`
|
||||||
|
exportées : `wails generate module`.
|
||||||
|
- **Release :** `.vscode/release.ps1` (Ctrl+Maj+P → *Tasks: Run Task* →
|
||||||
|
*Release OpsLog*) — incrémente la version, pousse le source sur Gitea, compile
|
||||||
|
l'exe et le publie sur les releases Gitea + GitHub.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Journalisation
|
||||||
|
|
||||||
|
- **Bandeau de saisie façon Log4OM :** indicatif, RST émis/reçu, nom/QTH/locator,
|
||||||
|
bande/mode, fréquence TX/RX (split), heure de début/fin, commentaire/note. Le
|
||||||
|
**drapeau** de l'entité contactée est affiché en grand à côté des champs RST.
|
||||||
|
- **Recherche d'indicatif** (QRZ.com / HamQTH) avec photo, pré-remplissage du
|
||||||
|
nom/QTH/locator et onglet QRZ.com.
|
||||||
|
- **Résolution DXCC hors ligne** depuis `cty.dat` (pays, zones CQ/ITU,
|
||||||
|
continent), avec gestion des `/MM` `/AM` `/B` (balise) et des changements de
|
||||||
|
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),
|
||||||
|
re-résolution en masse depuis cty/QRZ/ClubLog, envoi en masse vers les services
|
||||||
|
QSL.
|
||||||
|
- **Constructeur de filtres QSO avancé** (champ / opérateur / valeur, ET / OU,
|
||||||
|
préréglages enregistrés) avec **export ADIF** des lignes filtrées ou
|
||||||
|
sélectionnées.
|
||||||
|
- **Recherche de doublons** (Outils) — regroupe les QSO par même indicatif +
|
||||||
|
bande + mode (optionnellement même jour / minute) et permet de choisir lesquels
|
||||||
|
supprimer.
|
||||||
|
- **Conforme ADIF 3.1.7** en import/export : dictionnaire complet des champs,
|
||||||
|
30 colonnes promues, éditeur générique de « champs supplémentaires » et modes
|
||||||
|
d'export standard/complet.
|
||||||
|
- **Profils :** chaque réglage est par profil ; chaque profil peut pointer son
|
||||||
|
journal vers le fichier SQLite local ou une base **MySQL partagée**
|
||||||
|
(multi-opérateur).
|
||||||
|
|
||||||
|
## Cartes & antenne
|
||||||
|
|
||||||
|
- **Vue principale = deux volets configurables** (par profil, Réglages → Général
|
||||||
|
→ *Vue principale*) : carte grand-cercle, carte locator (rue), la grille du
|
||||||
|
cluster, la grille des déjà-contactés, les QSO récents, les **commandes
|
||||||
|
FlexRadio**, la **console Icom** ou le panneau **Net control**.
|
||||||
|
- **Carte grand-cercle** avec distance & azimut trajet court/long, fonds de carte
|
||||||
|
sélectionnables (Light / Voyager / Street / Satellite, tous sans clé et
|
||||||
|
légendés) et le(s) **lobe(s) du faisceau d'antenne** tracés depuis l'azimut du
|
||||||
|
rotor.
|
||||||
|
- **Compas de rotor** (azimutal équidistant, clic pour tourner) piloté par
|
||||||
|
PstRotator.
|
||||||
|
- **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
|
||||||
|
et sur la carte, pour toujours savoir où pointe l'antenne.
|
||||||
|
|
||||||
|
## Cluster DX
|
||||||
|
|
||||||
|
- Plusieurs serveurs de cluster avec reconnexion auto, un maître pour les
|
||||||
|
commandes.
|
||||||
|
- **Barre latérale de filtres** (recherche d'indicatif, masquer-déjà-contactés,
|
||||||
|
grouper les doublons, bande / mode / statut / source) partagée entre l'onglet
|
||||||
|
Cluster et le volet cluster de la vue principale, avec bascule affichage/masquage.
|
||||||
|
- **Statut** par spot (nouveau / nouvelle bande / nouveau créneau / contacté),
|
||||||
|
clic pour accorder la radio, et une **bandmap** multi-bandes (bandes façon
|
||||||
|
panadapter).
|
||||||
|
- Les spots **POTA** sont étiquetés avec leur référence de parc (via
|
||||||
|
`api.pota.app`).
|
||||||
|
- **Alertes de spots** (façon Log4OM) : règles sur indicatif / pays / bande /
|
||||||
|
mode / spotter, avec notification sonore, visuelle et e-mail (Outils →
|
||||||
|
*Gestion des alertes*).
|
||||||
|
|
||||||
|
## Contrôle CAT
|
||||||
|
|
||||||
|
Quatre backends natifs (Réglages → CAT), chacun avec reconnexion auto et une
|
||||||
|
connexion rapide non bloquante — une radio éteinte ne fige jamais l'application :
|
||||||
|
|
||||||
|
- **OmniRig** (Rig 1/2, changement à chaud) — fonctionne avec toute radio
|
||||||
|
supportée par OmniRig.
|
||||||
|
- **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
|
||||||
|
du cluster poussés sur l'écran Flex, clic → remplir l'indicatif).
|
||||||
|
- **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
|
||||||
|
RS-BA1 ni Remote Utility nécessaires.
|
||||||
|
- **TCI** (WebSocket) — SunSDR / ExpertSDR2 et tout serveur compatible TCI :
|
||||||
|
fréquence / mode / PTT / split, plus spots panorama optionnels.
|
||||||
|
|
||||||
|
Le mode est lu depuis la radio ; le sous-mode numérique (FT4 vs FT8) est déduit
|
||||||
|
de la fréquence. Les **antennes RX/TX Flex par bande** sont configurables et
|
||||||
|
appliquées automatiquement au changement de bande.
|
||||||
|
|
||||||
|
### Onglet de commande FlexRadio (façon SmartSDR)
|
||||||
|
|
||||||
|
Affiché uniquement quand le backend CAT est une FlexRadio :
|
||||||
|
|
||||||
|
- **Émission :** puissance RF, puissance d'accord, TUNE, MOX, processeur de
|
||||||
|
parole (NOR/DX/DX+), VOX (+ niveau + délai), moniteur (+ niveau), gain micro.
|
||||||
|
- **Réception (slice active) :** mode/seuil AGC, niveau audio, NB / NR / ANF.
|
||||||
|
- **Coupleur d'antenne (ATU) :** accord / bypass / mémoires.
|
||||||
|
- **Amplificateur :** PowerGenius XL operate/standby + défaut.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
### Onglet de commande Icom
|
||||||
|
|
||||||
|
Affiché quand le backend CAT est Icom (USB ou réseau). Une console complète façon
|
||||||
|
RS-BA1 :
|
||||||
|
|
||||||
|
- **Double afficheur VFO** (MAIN / SUB) avec la grande fréquence tabulaire, le
|
||||||
|
badge de mode, la bande et l'offset RIT/ΔTX, et une **rangée de boutons de
|
||||||
|
mode** (SSB / CW / RTTY / PSK / AM / FM).
|
||||||
|
- **Scope de spectre + waterfall** (panadapter) : ON/OFF, CTR/FIX, double-clic
|
||||||
|
pour accorder, et boutons **◀ ⊙ ▶** pour centrer le scope sur la fréquence
|
||||||
|
actuelle (±50 kHz) et le décaler à gauche/droite.
|
||||||
|
- **Mesures en direct** toujours visibles : S-mètre (clic → remplir le RST),
|
||||||
|
puissance en watts, ROS.
|
||||||
|
- **DSP réception :** gain AF / RF, squelch, AGC, préampli, atténuateur, filtre
|
||||||
|
(FIL1/2/3), NB, NR, ANF et — **en CW seulement** — l'**APF** (filtre de pic
|
||||||
|
audio).
|
||||||
|
- **Passe-bande / notch :** Twin PBT (intérieur / extérieur), notch manuel +
|
||||||
|
position.
|
||||||
|
- **Émission :** puissance RF, MOX, TUNE, **split avec offset automatique**
|
||||||
|
(+5 kHz en SSB, +1 kHz en CW), et moniteur. **En phonie seulement** : gain
|
||||||
|
micro, processeur de parole, VOX (+ gain + anti-VOX). Les commandes qui ne
|
||||||
|
s'appliquent pas au mode courant sont masquées automatiquement.
|
||||||
|
- **Bandes & antenne :** boutons de bande en un clic et sélection ANT1/ANT2.
|
||||||
|
- **Clarificateurs :** RIT et ΔTX avec accord molette / ± (Ctrl+←/→ décale le
|
||||||
|
RIT).
|
||||||
|
- Boutons **Marche / Arrêt** (manuels par choix — l'application ne réveille
|
||||||
|
jamais la radio à la connexion).
|
||||||
|
- La **manipulation CW** peut passer par le keyer intégré de la radio (voir
|
||||||
|
*Keyers* ci-dessous).
|
||||||
|
|
||||||
|
### Icom à distance (par internet, sans RS-BA1)
|
||||||
|
|
||||||
|
OpsLog parle directement le protocole réseau intégré de l'IC-7610 — il
|
||||||
|
**remplace à la fois l'Icom Remote Utility et RS-BA1**. Saisissez l'IP de la
|
||||||
|
radio, le nom/mot de passe Network User1 et l'adresse CI-V, et toute la console
|
||||||
|
Icom fonctionne sur le LAN/internet : login + token (renouvelé automatiquement),
|
||||||
|
tunnel CI-V, retransmission côté réception pour une liaison très stable même
|
||||||
|
avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmètre
|
||||||
|
— utilisez la radio en USB + une liaison voix comme Mumble.)
|
||||||
|
|
||||||
|
## Keyers & audio
|
||||||
|
|
||||||
|
- **Keyer CW** avec macros et macros sur touches F. Le moteur du keyer est
|
||||||
|
sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **Icom** (le keyer
|
||||||
|
intégré de la radio via CI-V — sans matériel supplémentaire, fonctionne aussi à
|
||||||
|
distance) ou **TCI**.
|
||||||
|
- **Keyer vocal numérique** (DVK) : enregistrer les messages vocaux F1–F6 et les
|
||||||
|
émettre.
|
||||||
|
- **Enregistrement audio des QSO :** capture continue en tampon glissant ; au
|
||||||
|
*Log QSO* le contact est sauvegardé dans un WAV par QSO
|
||||||
|
(`INDICATIF_AAAAMMJJ_HHMMSS.wav`) ; mixe RX + micro.
|
||||||
|
|
||||||
|
## Amplis & commutateurs
|
||||||
|
|
||||||
|
- Amplificateur **PowerGenius XL** (4O3A) — TCP direct : operate/standby,
|
||||||
|
sélecteur de mode ventilateur et affichage des défauts.
|
||||||
|
- Commutateur d'antenne **Antenna Genius** (4O3A) via TCP/GSCP — un widget de
|
||||||
|
commutation A/B ancré.
|
||||||
|
|
||||||
|
## QSL & diplômes
|
||||||
|
|
||||||
|
- **Moteur de diplômes :** définitions intégrées + personnalisées (partagées
|
||||||
|
**globalement** entre profils) — DXCC, WAS / WAZ / WAC, WPX,
|
||||||
|
IOTA / POTA / SOTA / WWFF, **DDFM**, contacté/confirmé/validé par bande & mode,
|
||||||
|
règles OU et affectation manuelle de références, détection de référence en
|
||||||
|
direct à la saisie de l'indicatif, **import de listes de références** pour les
|
||||||
|
totaux/noms, et un **Rescan** qui relit le journal (récupère les nouvelles
|
||||||
|
confirmations LoTW/QRZ).
|
||||||
|
- **Services QSL :** ClubLog (upload ADIF par lots), LoTW, QRZ.com, eQSL —
|
||||||
|
upload et **téléchargement des confirmations** (qui rafraîchit automatiquement
|
||||||
|
les stats de diplômes).
|
||||||
|
- **Concepteur de cartes QSL** (voir ci-dessous).
|
||||||
|
- **eQSL par e-mail :** clic droit sur un QSO → *Envoyer l'eQSL par e-mail* via
|
||||||
|
le compte SMTP configuré. (Outlook/Hotmail désactivent le SMTP basic-auth —
|
||||||
|
utilisez Gmail avec un mot de passe d'application, ou un mot de passe
|
||||||
|
d'application Microsoft.)
|
||||||
|
|
||||||
|
## Log de concours
|
||||||
|
|
||||||
|
- **Onglet Contest :** choisissez un concours (liste ADIF `CONTEST_ID` intégrée)
|
||||||
|
et un échange (numéro de série courant ou échange fixe). OpsLog remplit
|
||||||
|
automatiquement `CONTEST_ID` et les numéros émis/reçus (`STX` / `SRX`), impose
|
||||||
|
un début/fin de fenêtre, signale les doublons et tient un tableau de score en
|
||||||
|
direct.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
**TM74TFR**) : Réglages → Général → *Publier le statut opérateur en direct*.
|
||||||
|
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
|
||||||
|
~15 s. Un petit rendu PHP
|
||||||
|
([`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
|
||||||
|
pouvez intégrer sur la bio **QRZ.com** de la station
|
||||||
|
(`<img src="…/tm74-status.php?img=1">`). OpsLog écrit seulement dans la base —
|
||||||
|
ce n'est pas un serveur web.
|
||||||
|
|
||||||
|
## Net control
|
||||||
|
|
||||||
|
- **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
|
||||||
|
net d'un coup en utilisant la fréquence CAT.
|
||||||
|
|
||||||
|
## Apparence & langue
|
||||||
|
|
||||||
|
- **Thèmes :** quatre thèmes complets (Clair chaud, Sombre chaud, Graphite
|
||||||
|
sombre, Contraste élevé) plus **Auto** (suit la préférence clair/sombre du
|
||||||
|
système), sélectionnables dans Réglages → Général. Chaque panneau et chaque
|
||||||
|
table AG-Grid suit le thème.
|
||||||
|
- **Bilingue :** interface complète **anglais / français**, avec un choix de
|
||||||
|
drapeau au premier lancement et un sélecteur dans Réglages → Général.
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
- **Coffre à secrets :** chiffrement optionnel par phrase de passe des mots de
|
||||||
|
passe stockés (AES-GCM + PBKDF2). Les valeurs chiffrées sont portables ; une
|
||||||
|
seule invite de déverrouillage au lancement les déchiffre pour la session.
|
||||||
|
|
||||||
|
## Intégrations (sortantes)
|
||||||
|
|
||||||
|
- **Émetteurs UDP :** pousser la fréquence actuelle vers **PstRotator**, les
|
||||||
|
infos radio au format **N1MM `RadioInfo`**, ou un **enregistrement ADIF à
|
||||||
|
chaque QSO logué** — pour que les outils externes (contrôle de rotor,
|
||||||
|
applications numériques, autres logiciels de log) restent synchronisés.
|
||||||
|
|
||||||
|
## Divers
|
||||||
|
|
||||||
|
- **Démarrage automatique :** lancer des programmes externes (WSJT-X, JTAlert,
|
||||||
|
contrôle de rotor…) au démarrage d'OpsLog, en sautant ceux déjà lancés.
|
||||||
|
- **Sauvegarde :** sauvegarde optionnelle base + ADIF à la fermeture.
|
||||||
|
- **Vérification de mise à jour** au démarrage avec un toast (désactivable).
|
||||||
|
- **Télémétrie d'usage anonyme** (un battement de cœur quotidien : ID
|
||||||
|
d'installation aléatoire + version + OS — aucune donnée d'indicatif ou de QSO ;
|
||||||
|
désactivable dans les Préférences).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concepteur de cartes QSL
|
||||||
|
|
||||||
|
Outils → *Concepteur de cartes QSL…* transforme quelques photos en une carte
|
||||||
|
eQSL soignée :
|
||||||
|
|
||||||
|
1. Choisissez 1 à 6 photos (jpeg/png). OpsLog les analyse hors ligne (grille de
|
||||||
|
détail/luminance) et propose **3 designs** — indicatif dans la zone la plus
|
||||||
|
calme de la meilleure photo, nom de l'opérateur, zones CQ/ITU + ligne locator,
|
||||||
|
drapeau du pays, les autres photos en inserts encadrés, et un encart de
|
||||||
|
confirmation par QSO.
|
||||||
|
2. Choisissez une proposition et affinez-la : cliquez un élément pour le
|
||||||
|
sélectionner, glissez pour déplacer, changez la police / le préréglage de
|
||||||
|
style (gel or, gel argent, contour blanc classique, script, plat) et les
|
||||||
|
réglages par préréglage dans le panneau de droite.
|
||||||
|
3. Enregistrez le modèle (les photos sont copiées dans
|
||||||
|
`data/qsl/templates/<id>/`, les originaux peuvent donc être déplacés). Un
|
||||||
|
modèle peut être le défaut par profil.
|
||||||
|
|
||||||
|
Envoi : clic droit sur un QSO → *Envoyer l'eQSL par e-mail*. La carte est rendue
|
||||||
|
avec les données de ce QSO, matricée en JPEG ≤ 800 Ko, archivée dans
|
||||||
|
`data/qsl/outbox/` et envoyée via le compte SMTP configuré à l'adresse trouvée
|
||||||
|
par la recherche QRZ/HamQTH. En cas de succès le QSO est marqué `EQSL_SENT=Y`
|
||||||
|
(ADIF). Les modèles de sujet/corps de l'e-mail sont dans le concepteur (variables
|
||||||
|
`{CALL} {DATE} {BAND} {MODE} {MYCALL}`).
|
||||||
|
|
||||||
|
Polices : Archivo Black, Lilita One, Baloo 2, Oswald, Great Vibes, Allura
|
||||||
|
(toutes OFL, embarquées — licences dans `internal/qslcard/assets/fonts/`) ;
|
||||||
|
Cooper Black est proposée si MS Office l'a installée. Drapeaux : flag-icons
|
||||||
|
(MIT), embarqués pour les entités DXCC couramment contactées.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Données & stockage
|
||||||
|
|
||||||
|
- La **config** (réglages, profils, radios/antennes, nœuds de cluster, cache de
|
||||||
|
recherche, listes de diplômes, modèles QSL) réside toujours dans le fichier
|
||||||
|
SQLite local sous `data/` — instantané même quand le journal est sur un MySQL
|
||||||
|
lointain.
|
||||||
|
- Le **journal** (QSO) réside là où pointe le profil actif : le fichier SQLite
|
||||||
|
local ou une base **MySQL** partagée par profil.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*An English version of this document is available in [README.md](README.md).*
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
# OpsLog
|
# OpsLog
|
||||||
|
|
||||||
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
|
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
|
||||||
(OmniRig **and** native FlexRadio/SmartSDR), DX cluster, awards tracking, maps,
|
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
|
||||||
QSL management and a QSL-card designer. Built with **Wails v2** (Go backend +
|
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
|
||||||
|
DX cluster with spot alerts, awards tracking, maps, contest logging, QSL
|
||||||
|
management and a QSL-card designer. Built with **Wails v2** (Go backend +
|
||||||
React/TypeScript frontend), **pure Go** (no CGO): SQLite for configuration,
|
React/TypeScript frontend), **pure Go** (no CGO): SQLite for configuration,
|
||||||
optional **shared MySQL** for the logbook so several operators can run one log.
|
optional **shared MySQL** for the logbook so several operators can run one log.
|
||||||
|
Fully themeable and bilingual (English / French).
|
||||||
|
|
||||||
Developed by **F4BPO**.
|
Developed by **F4BPO**.
|
||||||
|
|
||||||
@@ -30,10 +33,16 @@ Developed by **F4BPO**.
|
|||||||
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
|
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
|
||||||
and the QRZ.com tab.
|
and the QRZ.com tab.
|
||||||
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
|
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
|
||||||
with `/MM` `/AM` and call-area (`/8`, `/W6`) handling, plus ClubLog DXpedition
|
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
|
||||||
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.
|
||||||
|
- **Advanced QSO filter builder** (field / operator / value, AND / OR, saved
|
||||||
|
presets) with filtered- and selected-row **ADIF export**.
|
||||||
|
- **Find duplicates** (Tools) — groups QSOs by same call + band + mode (optionally
|
||||||
|
same day / minute) and lets you pick which to delete.
|
||||||
|
- **ADIF 3.1.7 compliant** import/export: a full field dictionary, 30 promoted
|
||||||
|
columns, a generic "extra fields" editor and standard/all export modes.
|
||||||
- **Profiles:** every setting is per-profile; each profile can point its logbook
|
- **Profiles:** every setting is per-profile; each profile can point its logbook
|
||||||
at the local SQLite file or a **shared MySQL** database (multi-operator).
|
at the local SQLite file or a **shared MySQL** database (multi-operator).
|
||||||
|
|
||||||
@@ -41,7 +50,8 @@ Developed by **F4BPO**.
|
|||||||
|
|
||||||
- **Main view = two configurable panes** (per profile, Settings → General →
|
- **Main view = two configurable panes** (per profile, Settings → General →
|
||||||
*Main view*): great-circle map, locator (street) map, the cluster grid, the
|
*Main view*): great-circle map, locator (street) map, the cluster grid, the
|
||||||
worked-before grid, or the **FlexRadio controls**.
|
worked-before grid, recent QSOs, the **FlexRadio controls**, the **Icom
|
||||||
|
console** or the **Net control** panel.
|
||||||
- **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.
|
||||||
@@ -58,15 +68,29 @@ Developed by **F4BPO**.
|
|||||||
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).
|
||||||
|
- **POTA** spots are tagged with their park reference (via `api.pota.app`).
|
||||||
|
- **Spot alerts** (Log4OM-style): rules on call / country / band / mode /
|
||||||
|
spotter, with sound, visual and e-mail notification (Tools → *Alert
|
||||||
|
management*).
|
||||||
|
|
||||||
## CAT control
|
## CAT control
|
||||||
|
|
||||||
- **OmniRig** backend (Rig 1/2, hot-swap), and a native **FlexRadio (SmartSDR)**
|
Four native backends (Settings → CAT), each with auto-reconnect and a fast,
|
||||||
backend over the radio's TCP API — real-time slice freq/mode/split, auto
|
non-blocking connect so a powered-off radio never freezes the app:
|
||||||
reconnect, UDP discovery, and **panadapter spots** (cluster spots pushed to the
|
|
||||||
Flex display, click → fill the call).
|
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
|
||||||
- Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred
|
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
|
||||||
from the frequency.
|
mode / split, UDP discovery, and **panadapter spots** (cluster spots pushed to
|
||||||
|
the Flex display, click → fill the call).
|
||||||
|
- **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
|
||||||
|
Remote Utility needed.
|
||||||
|
- **TCI** (WebSocket) — SunSDR / ExpertSDR2 and any TCI-compatible server:
|
||||||
|
freq / mode / PTT / split, plus optional panorama spots.
|
||||||
|
|
||||||
|
Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred from
|
||||||
|
the frequency. **Per-band Flex RX/TX antennas** can be configured and are applied
|
||||||
|
automatically on band change.
|
||||||
|
|
||||||
### FlexRadio control tab (SmartSDR-style)
|
### FlexRadio control tab (SmartSDR-style)
|
||||||
|
|
||||||
@@ -80,19 +104,64 @@ Shown only when the CAT backend is a FlexRadio:
|
|||||||
- **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.
|
||||||
|
|
||||||
|
### Icom control tab
|
||||||
|
|
||||||
|
Shown when the CAT backend is Icom (USB or network). A full RS-BA1-style console:
|
||||||
|
|
||||||
|
- **Twin VFO readout** (MAIN / SUB) with the big tabular frequency, mode badge,
|
||||||
|
band and RIT/ΔTX offset, and a **mode-button row** (SSB / CW / RTTY / PSK /
|
||||||
|
AM / FM).
|
||||||
|
- **Spectrum scope + waterfall** (panadapter): ON/OFF, CTR/FIX, double-click to
|
||||||
|
tune, and **◀ ⊙ ▶** buttons to centre the scope on the current frequency
|
||||||
|
(±50 kHz) and pan left/right.
|
||||||
|
- **Live meters** always visible: S-meter (click → fill RST), power in watts, SWR.
|
||||||
|
- **Receive DSP:** AF / RF gain, squelch, AGC, preamp, attenuator, filter
|
||||||
|
(FIL1/2/3), NB, NR, ANF and — **on CW only** — the **APF** (audio peak filter).
|
||||||
|
- **Passband / notch:** Twin PBT (inner / outer), manual notch + position.
|
||||||
|
- **Transmit:** RF power, MOX, TUNE, **split with an automatic offset**
|
||||||
|
(+5 kHz on SSB, +1 kHz on CW), and monitor. On **voice modes only**: mic gain,
|
||||||
|
speech compressor, VOX (+ gain + anti-VOX). Controls that don't apply to the
|
||||||
|
current mode are hidden automatically.
|
||||||
|
- **Bands & antenna:** one-touch band buttons and ANT1/ANT2 selection.
|
||||||
|
- **Clarifiers:** RIT and ΔTX with wheel / ± tuning (Ctrl+←/→ nudges RIT).
|
||||||
|
- **Power ON / OFF** buttons (manual by design — the app never wakes the rig on
|
||||||
|
connect).
|
||||||
|
- **CW keying** can run through the radio's own keyer (see *Keyers* below).
|
||||||
|
|
||||||
|
### Remote Icom (over the internet, no RS-BA1)
|
||||||
|
|
||||||
|
OpsLog speaks the IC-7610's built-in network protocol directly — it **replaces
|
||||||
|
both the Icom Remote Utility and RS-BA1**. Enter the radio's IP, the Network
|
||||||
|
User1 name/password and the CI-V address, and the whole Icom console works over
|
||||||
|
the LAN/internet: login + token (auto-renewed), CI-V tunnel, receive-side
|
||||||
|
retransmit for a rock-solid link even with the panadapter streaming, and manual
|
||||||
|
power ON/OFF. (Audio is out of scope — use the radio in USB + a voice link such
|
||||||
|
as Mumble.)
|
||||||
|
|
||||||
## Keyers & audio
|
## Keyers & audio
|
||||||
|
|
||||||
- **WinKeyer** CW keyer (macros, F-key macros, auto-call repeat).
|
- **CW keyer** with macros and F-key macros. The keyer engine is selectable:
|
||||||
- **Digital Voice Keyer** (DVK) message playback.
|
**WinKeyer** (K1EL WK1/2/3 over a COM port), **Icom** (the radio's own keyer
|
||||||
- **QSO audio recording** (SSB/DAX) archived per QSO; disabled for CW (no DAX
|
over CI-V — no extra hardware, works over the remote link too) or **TCI**.
|
||||||
audio in CW).
|
- **Digital Voice Keyer** (DVK): record F1–F6 voice messages and transmit them.
|
||||||
|
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
|
||||||
|
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
|
||||||
|
|
||||||
|
## Amplifiers & switches
|
||||||
|
|
||||||
|
- **PowerGenius XL** (4O3A) amplifier — direct TCP: operate/standby, fan-mode
|
||||||
|
selector and fault display.
|
||||||
|
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
||||||
|
antenna-switch widget.
|
||||||
|
|
||||||
## QSL & awards
|
## QSL & awards
|
||||||
|
|
||||||
- **Awards engine:** built-in + custom award definitions (shared **globally**
|
- **Awards engine:** built-in + custom award definitions (shared **globally**
|
||||||
across profiles), worked/confirmed/validated by band & mode, OR rules and
|
across profiles) — DXCC, WAS / WAZ / WAC, WPX, IOTA / POTA / SOTA / WWFF,
|
||||||
manual reference assignment, live reference detection on call entry, and a
|
**DDFM**, worked/confirmed/validated by band & mode, OR rules and manual
|
||||||
**Rescan** that re-pulls the logbook (picks up fresh LoTW/QRZ confirmations).
|
reference assignment, live reference detection on call entry, **reference-list
|
||||||
|
import** for totals/names, and a **Rescan** that re-pulls the logbook (picks up
|
||||||
|
fresh LoTW/QRZ confirmations).
|
||||||
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL — upload
|
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL — upload
|
||||||
and **confirmation download** (which auto-refreshes the award stats).
|
and **confirmation download** (which auto-refreshes the award stats).
|
||||||
- **QSL Card Designer** (see below).
|
- **QSL Card Designer** (see below).
|
||||||
@@ -100,6 +169,13 @@ Shown only when the CAT backend is a FlexRadio:
|
|||||||
SMTP account. (Outlook/Hotmail disable basic-auth SMTP — use Gmail with an app
|
SMTP account. (Outlook/Hotmail disable basic-auth SMTP — use Gmail with an app
|
||||||
password, or a Microsoft app password.)
|
password, or a Microsoft app password.)
|
||||||
|
|
||||||
|
## Contest logging
|
||||||
|
|
||||||
|
- **Contest tab:** pick a contest (built-in ADIF `CONTEST_ID` list) and an
|
||||||
|
exchange (running serial or a fixed exchange). OpsLog auto-fills `CONTEST_ID`
|
||||||
|
and the sent/received serials (`STX` / `SRX`), enforces a window start/end,
|
||||||
|
flags dupes and keeps a live scoreboard.
|
||||||
|
|
||||||
## 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**):
|
||||||
@@ -111,10 +187,37 @@ 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
|
||||||
only writes to the DB — it is not a web server.
|
only writes to the DB — it is not a web server.
|
||||||
|
|
||||||
|
## Net control
|
||||||
|
|
||||||
|
- **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
|
||||||
|
using the CAT frequency.
|
||||||
|
|
||||||
|
## Appearance & language
|
||||||
|
|
||||||
|
- **Themes:** four complete themes (Warm light, Warm dark, Graphite dark, High
|
||||||
|
contrast) plus **Auto** (follows the OS light/dark preference), selectable in
|
||||||
|
Settings → General. Every panel and every AG-Grid table follows the theme.
|
||||||
|
- **Bilingual:** full **English / French** UI, with a first-run flag chooser and
|
||||||
|
a switcher in Settings → General.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **Secret vault:** opt-in passphrase encryption of the stored passwords
|
||||||
|
(AES-GCM + PBKDF2). Encrypted values are portable; a single unlock prompt at
|
||||||
|
launch decrypts them for the session.
|
||||||
|
|
||||||
|
## Integrations (outbound)
|
||||||
|
|
||||||
|
- **UDP emitters:** push the current frequency to **PstRotator**, radio info in
|
||||||
|
**N1MM `RadioInfo`** format, or an **ADIF record on each logged QSO** — so
|
||||||
|
external tools (rotator control, digital apps, other loggers) stay in sync.
|
||||||
|
|
||||||
## Other
|
## Other
|
||||||
|
|
||||||
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
||||||
OpsLog startup, skipping any already running.
|
OpsLog startup, skipping any already running.
|
||||||
|
- **Backup:** optional database + ADIF backup at shutdown.
|
||||||
- **Update check** at startup with a toast (toggleable).
|
- **Update check** at startup with a toast (toggleable).
|
||||||
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
||||||
version + OS — no callsign or QSO data; opt-out in Preferences).
|
version + OS — no callsign or QSO data; opt-out in Preferences).
|
||||||
@@ -156,3 +259,7 @@ commonly-worked DXCC entities.
|
|||||||
`data/` — instant even when the logbook is on a far-away MySQL.
|
`data/` — instant even when the logbook is on a far-away MySQL.
|
||||||
- **Logbook** (QSOs) lives where the active profile points it: the local SQLite
|
- **Logbook** (QSOs) lives where the active profile points it: the local SQLite
|
||||||
file or a per-profile shared **MySQL** database.
|
file or a per-profile shared **MySQL** database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*A French version of this document is available in [README.fr.md](README.fr.md).*
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -92,6 +93,9 @@ const (
|
|||||||
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
|
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
|
||||||
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
|
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
|
||||||
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
|
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
|
||||||
|
keyCATIcomNetHost = "cat.icom.net.host" // Icom network remote: rig IP/hostname
|
||||||
|
keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID
|
||||||
|
keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password
|
||||||
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
|
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
|
||||||
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
|
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
|
||||||
keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama
|
keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama
|
||||||
@@ -153,6 +157,7 @@ const (
|
|||||||
// port is fixed at 9007, so only the IP is configurable.
|
// port is fixed at 9007, so only the IP is configurable.
|
||||||
keyAntGeniusEnabled = "antgenius.enabled"
|
keyAntGeniusEnabled = "antgenius.enabled"
|
||||||
keyAntGeniusHost = "antgenius.host"
|
keyAntGeniusHost = "antgenius.host"
|
||||||
|
keyAntGeniusPassword = "antgenius.password" // remote/AUTH password (blank on LAN)
|
||||||
|
|
||||||
// PowerGenius XL (4O3A) amplifier fan-mode control — Hardware → PowerGenius.
|
// PowerGenius XL (4O3A) amplifier fan-mode control — Hardware → PowerGenius.
|
||||||
keyPGXLEnabled = "pgxl.enabled"
|
keyPGXLEnabled = "pgxl.enabled"
|
||||||
@@ -176,7 +181,7 @@ 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" | "tci"
|
keyWKEngine = "winkeyer.engine" // "winkeyer" | "icom" | "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
|
||||||
|
|
||||||
@@ -263,7 +268,7 @@ type QSLDefaults struct {
|
|||||||
// individual key/value pairs to keep the settings table flat.
|
// individual key/value pairs to keep the settings table flat.
|
||||||
type CATSettings struct {
|
type CATSettings struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Backend string `json:"backend"` // "omnirig" | "flex" | "icom"
|
Backend string `json:"backend"` // "omnirig" | "flex" | "icom" | "icom-net" | "tci"
|
||||||
OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot)
|
OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot)
|
||||||
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
|
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
|
||||||
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
|
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
|
||||||
@@ -271,6 +276,9 @@ type CATSettings struct {
|
|||||||
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
|
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
|
||||||
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
|
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
|
||||||
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
|
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
|
||||||
|
IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server)
|
||||||
|
IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID
|
||||||
|
IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password
|
||||||
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
|
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
|
||||||
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
|
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
|
||||||
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
|
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
|
||||||
@@ -459,6 +467,12 @@ type App struct {
|
|||||||
opLat float64
|
opLat float64
|
||||||
opLon float64
|
opLon float64
|
||||||
opSet bool
|
opSet bool
|
||||||
|
opCall string // active profile callsign, cached for outbound UDP emitters
|
||||||
|
|
||||||
|
// Dedup for the frequency/mode outbound UDP emitters (PstRotator, N1MM): only
|
||||||
|
// send when the frequency or mode actually changes.
|
||||||
|
udpLastFreqHz int64
|
||||||
|
udpLastMode string
|
||||||
}
|
}
|
||||||
|
|
||||||
// gridToLatLon parses a Maidenhead locator (4 or 6 chars) and returns the
|
// gridToLatLon parses a Maidenhead locator (4 or 6 chars) and returns the
|
||||||
@@ -534,6 +548,7 @@ func (a *App) refreshOperatorGrid() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a.opCall = strings.ToUpper(strings.TrimSpace(p.Callsign))
|
||||||
lat, lon, ok := gridToLatLon(p.MyGrid)
|
lat, lon, ok := gridToLatLon(p.MyGrid)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -543,6 +558,36 @@ func (a *App) refreshOperatorGrid() {
|
|||||||
a.opSet = true
|
a.opSet = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emitRadioUDP forwards the rig's frequency/mode to any enabled outbound UDP
|
||||||
|
// emitters (PstRotator, N1MM RadioInfo). Deduped so it only fires when the
|
||||||
|
// operating frequency or mode actually changes. The send runs off the CAT
|
||||||
|
// goroutine so a slow/failed datagram can't stall polling.
|
||||||
|
func (a *App) emitRadioUDP(s cat.RigState) {
|
||||||
|
if a.udp == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rx := s.FreqHz
|
||||||
|
if s.RxFreqHz != 0 { // split: RxFreqHz is the active/listening VFO
|
||||||
|
rx = s.RxFreqHz
|
||||||
|
}
|
||||||
|
if rx <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rx == a.udpLastFreqHz && s.Mode == a.udpLastMode {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.udpLastFreqHz = rx
|
||||||
|
a.udpLastMode = s.Mode
|
||||||
|
rs := udp.RadioState{
|
||||||
|
StationName: a.opCall,
|
||||||
|
OpCall: a.opCall,
|
||||||
|
RxFreqHz: rx,
|
||||||
|
TxFreqHz: s.FreqHz,
|
||||||
|
Mode: s.Mode,
|
||||||
|
}
|
||||||
|
go a.udp.EmitRadioState(rs)
|
||||||
|
}
|
||||||
|
|
||||||
// dxccAdapter bridges *dxcc.Manager to the lookup.DXCCResolver interface
|
// dxccAdapter bridges *dxcc.Manager to the lookup.DXCCResolver interface
|
||||||
// without making the lookup package import dxcc.
|
// without making the lookup package import dxcc.
|
||||||
type dxccAdapter struct{ m *dxcc.Manager }
|
type dxccAdapter struct{ m *dxcc.Manager }
|
||||||
@@ -582,17 +627,15 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// Windows reinstall. It lives OUTSIDE the DB since we must know the path
|
// Windows reinstall. It lives OUTSIDE the DB since we must know the path
|
||||||
// before opening it.
|
// before opening it.
|
||||||
if custom := readDBPointer(dataDir); custom != "" {
|
if custom := readDBPointer(dataDir); custom != "" {
|
||||||
// Portability guard: a pointer that is merely ANOTHER folder's default DB
|
// Honour any chosen database that still exists on disk. Only fall back to
|
||||||
// location ("…/<other>/data/opslog.db") means the portable folder was
|
// this folder's own default when the pointed-to file is genuinely GONE —
|
||||||
// renamed or copied — its config.json still points at the original. Ignore
|
// the portable folder was copied to a machine without the original drive,
|
||||||
// it and use THIS folder's own data (and clear the stale pointer so it
|
// or the path was deleted — and clear the now-dangling pointer so it stops
|
||||||
// stops happening). A genuine custom location — another drive, a different
|
// trying. (The old heuristic guessed "folder moved" from the file name and
|
||||||
// filename — is NOT default-style, so it's still honoured.
|
// wrongly discarded valid logbooks named …/data/opslog.db, sending the user
|
||||||
stale := strings.EqualFold(filepath.Base(custom), "opslog.db") &&
|
// back to the default every launch — the save appeared not to stick.)
|
||||||
strings.EqualFold(filepath.Base(filepath.Dir(custom)), "data") &&
|
if _, err := os.Stat(custom); err != nil {
|
||||||
!strings.EqualFold(filepath.Clean(filepath.Dir(custom)), filepath.Clean(dataDir))
|
fmt.Printf("OpsLog: chosen database %q not found (%v) — using %s\n", custom, err, a.dbPath)
|
||||||
if stale {
|
|
||||||
fmt.Printf("OpsLog: ignoring stale DB pointer %q (folder moved) — using %s\n", custom, a.dbPath)
|
|
||||||
_ = writeDBPointer(dataDir, "")
|
_ = writeDBPointer(dataDir, "")
|
||||||
} else {
|
} else {
|
||||||
a.dbPath = custom
|
a.dbPath = custom
|
||||||
@@ -726,11 +769,13 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
fmt.Printf("OpsLog: clublog cty.xml loaded — %d exceptions (%s)\n", n, d)
|
fmt.Printf("OpsLog: clublog cty.xml loaded — %d exceptions (%s)\n", n, d)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
// CAT manager: emit pushes state to the frontend via Wails events.
|
// CAT manager: emit pushes state to the frontend via Wails events, and
|
||||||
|
// forwards frequency/mode to any outbound UDP emitters (PstRotator, N1MM).
|
||||||
a.cat = cat.NewManager(func(s cat.RigState) {
|
a.cat = cat.NewManager(func(s cat.RigState) {
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
||||||
}
|
}
|
||||||
|
a.emitRadioUDP(s)
|
||||||
})
|
})
|
||||||
a.reloadCAT()
|
a.reloadCAT()
|
||||||
|
|
||||||
@@ -1068,6 +1113,13 @@ func (a *App) shutdown(ctx context.Context) {
|
|||||||
if a.udp != nil {
|
if a.udp != nil {
|
||||||
a.udp.StopAll()
|
a.udp.StopAll()
|
||||||
}
|
}
|
||||||
|
// Stop CAT so the backend disconnects cleanly. Critical for the Icom network
|
||||||
|
// backend: without this the rig never gets a disconnect and holds its single
|
||||||
|
// control session for minutes, refusing every new login (even from the Icom
|
||||||
|
// Remote Utility) until it times out on its own.
|
||||||
|
if a.cat != nil {
|
||||||
|
a.cat.Stop()
|
||||||
|
}
|
||||||
if a.winkeyer != nil {
|
if a.winkeyer != nil {
|
||||||
a.winkeyer.Disconnect()
|
a.winkeyer.Disconnect()
|
||||||
}
|
}
|
||||||
@@ -1487,6 +1539,26 @@ func (a *App) QuitApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RestartApp relaunches OpsLog and closes this instance, so a database change
|
||||||
|
// (the DB pointer is read once at startup) applies without the user manually
|
||||||
|
// reopening the app. The new process opens the SQLite files while this one is
|
||||||
|
// closing; SQLite's busy_timeout(5000) covers that brief overlap.
|
||||||
|
func (a *App) RestartApp() error {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("locate executable: %w", err)
|
||||||
|
}
|
||||||
|
cmd := exec.Command(exe)
|
||||||
|
cmd.Dir = filepath.Dir(exe)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("relaunch OpsLog: %w", err)
|
||||||
|
}
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.Quit(a.ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// reloadLookupProviders rebuilds the lookup chain from current settings.
|
// reloadLookupProviders rebuilds the lookup chain from current settings.
|
||||||
// Called at startup and after the user saves new credentials.
|
// Called at startup and after the user saves new credentials.
|
||||||
//
|
//
|
||||||
@@ -1583,6 +1655,10 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
a.extsvc.OnQSOLogged(id)
|
a.extsvc.OnQSOLogged(id)
|
||||||
}
|
}
|
||||||
a.maybeAutoSendEQSL(q)
|
a.maybeAutoSendEQSL(q)
|
||||||
|
// Forward the ADIF of this QSO to any outbound "ADIF message" UDP rows.
|
||||||
|
if a.udp != nil {
|
||||||
|
go a.udp.EmitLoggedADIF(adif.SingleRecordADIF(q))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
@@ -2830,8 +2906,10 @@ func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error)
|
|||||||
defs := a.awardDefs()
|
defs := a.awardDefs()
|
||||||
metas := a.awardRefMetas(defs)
|
metas := a.awardRefMetas(defs)
|
||||||
fieldByCode := map[string]string{}
|
fieldByCode := map[string]string{}
|
||||||
|
dispByCode := map[string]string{}
|
||||||
for _, d := range defs {
|
for _, d := range defs {
|
||||||
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
|
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
|
||||||
|
dispByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.RefDisplay))
|
||||||
}
|
}
|
||||||
nameOf := func(field, ref string) string {
|
nameOf := func(field, ref string) string {
|
||||||
switch field {
|
switch field {
|
||||||
@@ -2857,11 +2935,23 @@ func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error)
|
|||||||
if !rf.Worked {
|
if !rf.Worked {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// DXCC's ref is a number → show the country name instead.
|
// Per-award display choice: ref (default), name (description), or
|
||||||
|
// both. DXCC keeps showing the country name under the default.
|
||||||
label := rf.Ref
|
label := rf.Ref
|
||||||
|
switch dispByCode[code] {
|
||||||
|
case "name":
|
||||||
|
if rf.Name != "" {
|
||||||
|
label = rf.Name
|
||||||
|
}
|
||||||
|
case "both":
|
||||||
|
if rf.Name != "" {
|
||||||
|
label = rf.Ref + " — " + rf.Name
|
||||||
|
}
|
||||||
|
default: // "" or "ref"
|
||||||
if dxccField && rf.Name != "" {
|
if dxccField && rf.Name != "" {
|
||||||
label = rf.Name
|
label = rf.Name
|
||||||
}
|
}
|
||||||
|
}
|
||||||
refs = append(refs, label)
|
refs = append(refs, label)
|
||||||
}
|
}
|
||||||
if len(refs) > 0 {
|
if len(refs) > 0 {
|
||||||
@@ -3539,6 +3629,24 @@ var bulkFieldColumns = map[string]string{
|
|||||||
"my_antenna": "my_antenna",
|
"my_antenna": "my_antenna",
|
||||||
"my_sig": "my_sig",
|
"my_sig": "my_sig",
|
||||||
"my_sig_info": "my_sig_info",
|
"my_sig_info": "my_sig_info",
|
||||||
|
// Contest
|
||||||
|
"contest_id": "contest_id",
|
||||||
|
"srx_string": "srx_string",
|
||||||
|
"stx_string": "stx_string",
|
||||||
|
"arrl_sect": "arrl_sect",
|
||||||
|
"precedence": "precedence",
|
||||||
|
"class": "class",
|
||||||
|
// Propagation / satellite
|
||||||
|
"prop_mode": "prop_mode",
|
||||||
|
"sat_name": "sat_name",
|
||||||
|
"sat_mode": "sat_mode",
|
||||||
|
// Contacted station activation refs / SIG
|
||||||
|
"pota_ref": "pota_ref",
|
||||||
|
"sota_ref": "sota_ref",
|
||||||
|
"wwff_ref": "wwff_ref",
|
||||||
|
"iota": "iota",
|
||||||
|
"sig": "sig",
|
||||||
|
"sig_info": "sig_info",
|
||||||
// Misc text
|
// Misc text
|
||||||
"comment": "comment",
|
"comment": "comment",
|
||||||
"notes": "notes",
|
"notes": "notes",
|
||||||
@@ -4213,7 +4321,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
|
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CATSettings{}, err
|
return CATSettings{}, err
|
||||||
}
|
}
|
||||||
@@ -4227,6 +4335,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
IcomPort: m[keyCATIcomPort],
|
IcomPort: m[keyCATIcomPort],
|
||||||
IcomBaud: 115200,
|
IcomBaud: 115200,
|
||||||
IcomAddr: 0x98, // IC-7610 default
|
IcomAddr: 0x98, // IC-7610 default
|
||||||
|
IcomNetHost: m[keyCATIcomNetHost],
|
||||||
|
IcomNetUser: m[keyCATIcomNetUser],
|
||||||
|
IcomNetPass: m[keyCATIcomNetPass],
|
||||||
TCIHost: m[keyCATTCIHost],
|
TCIHost: m[keyCATTCIHost],
|
||||||
TCIPort: 40001,
|
TCIPort: 40001,
|
||||||
TCISpots: m[keyCATTCISpots] == "1",
|
TCISpots: m[keyCATTCISpots] == "1",
|
||||||
@@ -4315,6 +4426,9 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
||||||
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
|
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
|
||||||
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
|
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
|
||||||
|
keyCATIcomNetHost: strings.TrimSpace(s.IcomNetHost),
|
||||||
|
keyCATIcomNetUser: strings.TrimSpace(s.IcomNetUser),
|
||||||
|
keyCATIcomNetPass: s.IcomNetPass,
|
||||||
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
|
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
|
||||||
keyCATTCIPort: strconv.Itoa(s.TCIPort),
|
keyCATTCIPort: strconv.Itoa(s.TCIPort),
|
||||||
keyCATTCISpots: tciSpots,
|
keyCATTCISpots: tciSpots,
|
||||||
@@ -4898,6 +5012,8 @@ func (a *App) NetLookup(callsign string) netctl.Station {
|
|||||||
if lr, err := a.lookup.Lookup(a.ctx, st.Callsign); err == nil {
|
if lr, err := a.lookup.Lookup(a.ctx, st.Callsign); err == nil {
|
||||||
st.Name, st.QTH, st.Country = lr.Name, lr.QTH, lr.Country
|
st.Name, st.QTH, st.Country = lr.Name, lr.QTH, lr.Country
|
||||||
st.DXCC, st.CQ, st.ITU = lr.DXCC, lr.CQZ, lr.ITUZ
|
st.DXCC, st.CQ, st.ITU = lr.DXCC, lr.CQZ, lr.ITUZ
|
||||||
|
st.Grid, st.Address, st.State, st.Cnty = lr.Grid, lr.Address, lr.State, lr.County
|
||||||
|
st.Cont, st.Lat, st.Lon, st.Email = lr.Continent, lr.Lat, lr.Lon, lr.Email
|
||||||
}
|
}
|
||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
@@ -4988,6 +5104,16 @@ func (a *App) NetActivate(callsign string) (qso.QSO, error) {
|
|||||||
for _, st := range net.Stations {
|
for _, st := range net.Stations {
|
||||||
if strings.EqualFold(st.Callsign, call) {
|
if strings.EqualFold(st.Callsign, call) {
|
||||||
q.Name, q.QTH, q.Country = st.Name, st.QTH, st.Country
|
q.Name, q.QTH, q.Country = st.Name, st.QTH, st.Country
|
||||||
|
q.Grid, q.Address, q.State, q.County = st.Grid, st.Address, st.State, st.Cnty
|
||||||
|
q.Continent, q.Email = st.Cont, st.Email
|
||||||
|
if st.Lat != 0 {
|
||||||
|
lat := st.Lat
|
||||||
|
q.Lat = &lat
|
||||||
|
}
|
||||||
|
if st.Lon != 0 {
|
||||||
|
lon := st.Lon
|
||||||
|
q.Lon = &lon
|
||||||
|
}
|
||||||
if st.DXCC != 0 {
|
if st.DXCC != 0 {
|
||||||
d := st.DXCC
|
d := st.DXCC
|
||||||
q.DXCC = &d
|
q.DXCC = &d
|
||||||
@@ -7666,9 +7792,54 @@ func (a *App) SetCATFrequency(hz int64) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
applog.Printf("cat: SetFrequency(%d Hz) dispatch error: %v", hz, err)
|
applog.Printf("cat: SetFrequency(%d Hz) dispatch error: %v", hz, err)
|
||||||
}
|
}
|
||||||
|
// Re-tune the Ultrabeam right away on a DELIBERATE freq set (spot click, band
|
||||||
|
// button, memory recall) instead of waiting for the ~1.5 s follow poll + the
|
||||||
|
// CAT echo — the reason it used to only follow after you nudged the VFO.
|
||||||
|
go a.ultrabeamFollowNow(hz)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ultrabeamFollowNow re-tunes the Ultrabeam to freqHz at once (best-effort),
|
||||||
|
// honouring the same enabled/follow/in-range/step-deadband rules as the follow
|
||||||
|
// loop. Called from SetCATFrequency so a spot click moves the antenna instantly.
|
||||||
|
func (a *App) ultrabeamFollowNow(freqHz int64) {
|
||||||
|
c := a.ultrabeam
|
||||||
|
if c == nil || freqHz <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, err := a.GetUltrabeamSettings()
|
||||||
|
if err != nil || !s.Enabled || !s.Follow {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
step := s.StepKHz
|
||||||
|
if step <= 0 {
|
||||||
|
step = 50
|
||||||
|
}
|
||||||
|
st, err := c.GetStatus()
|
||||||
|
if err != nil || st == nil || !st.Connected {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if st.FreqMin > 0 && st.FreqMax > 0 {
|
||||||
|
mhz := freqHz / 1_000_000
|
||||||
|
if mhz < int64(st.FreqMin) || mhz > int64(st.FreqMax) {
|
||||||
|
return // outside the antenna's tunable range
|
||||||
|
}
|
||||||
|
}
|
||||||
|
khz := int(freqHz / 1000)
|
||||||
|
diff := khz - st.Frequency
|
||||||
|
if diff < 0 {
|
||||||
|
diff = -diff
|
||||||
|
}
|
||||||
|
if st.Frequency > 0 && diff < step {
|
||||||
|
return // within the deadband — don't chase a tiny QSY
|
||||||
|
}
|
||||||
|
if err := c.SetFrequency(khz, st.Direction); err != nil {
|
||||||
|
applog.Printf("ultrabeam: immediate re-tune to %d kHz failed: %v", khz, err)
|
||||||
|
} else {
|
||||||
|
applog.Printf("ultrabeam: re-tuned on freq set → %d kHz (dir %d)", khz, st.Direction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SetCATMode sets the rig's mode. ADIF mode names (SSB / CW / FT8 / …) are
|
// SetCATMode sets the rig's mode. ADIF mode names (SSB / CW / FT8 / …) are
|
||||||
// translated to backend-specific values by the backend itself.
|
// translated to backend-specific values by the backend itself.
|
||||||
func (a *App) SetCATMode(mode string) error {
|
func (a *App) SetCATMode(mode string) error {
|
||||||
@@ -7761,6 +7932,13 @@ func (a *App) IcomSetANF(on bool) error {
|
|||||||
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetANF(on) })
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetANF(on) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) IcomSetAPF(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAPF(on) })
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) IcomSetAGC(mode string) error {
|
func (a *App) IcomSetAGC(mode string) error {
|
||||||
if a.cat == nil {
|
if a.cat == nil {
|
||||||
return fmt.Errorf("cat not initialized")
|
return fmt.Errorf("cat not initialized")
|
||||||
@@ -7810,6 +7988,97 @@ func (a *App) IcomSetSplit(on bool) error {
|
|||||||
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetIcomSplit(on) })
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetIcomSplit(on) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) IcomSetAntenna(n int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAntenna(n) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) IcomSetPBTInner(p int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPBTInner(p) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) IcomSetPBTOuter(p int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPBTOuter(p) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) IcomSetManualNotch(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetManualNotch(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) IcomSetNotchPos(p int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNotchPos(p) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) IcomSetSquelch(p int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetSquelch(p) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) IcomSetComp(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetComp(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) IcomSetCompLevel(p int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetCompLevel(p) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) IcomSetMonitor(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetMonitor(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) IcomSetMonLevel(p int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetMonLevel(p) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) IcomSetVOX(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetVOX(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) IcomSetVOXGain(p int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetVOXGain(p) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) IcomSetAntiVOX(p int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAntiVOX(p) })
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) IcomTune() error {
|
func (a *App) IcomTune() error {
|
||||||
if a.cat == nil {
|
if a.cat == nil {
|
||||||
return fmt.Errorf("cat not initialized")
|
return fmt.Errorf("cat not initialized")
|
||||||
@@ -7817,6 +8086,15 @@ func (a *App) IcomTune() error {
|
|||||||
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.TuneATU() })
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.TuneATU() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IcomSetPower turns the radio on or off (manual — the app never wakes the rig
|
||||||
|
// on connect). ON sends a wake preamble + CI-V 0x18 01; the rig then boots ~15s.
|
||||||
|
func (a *App) IcomSetPower(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPower(on) })
|
||||||
|
}
|
||||||
|
|
||||||
// IcomSetScope enables/disables the spectrum-scope waveform stream.
|
// IcomSetScope enables/disables the spectrum-scope waveform stream.
|
||||||
func (a *App) IcomSetScope(on bool) error {
|
func (a *App) IcomSetScope(on bool) error {
|
||||||
if a.cat == nil {
|
if a.cat == nil {
|
||||||
@@ -7842,6 +8120,75 @@ func (a *App) IcomSetScopeMode(fixed bool) error {
|
|||||||
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetScopeMode(fixed) })
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetScopeMode(fixed) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IcomSetScopeEdges points the fixed-mode scope at [lowHz, highHz] — the panel's
|
||||||
|
// "centre on VFO" (VFO±50 kHz) and pan ◀/▶ buttons.
|
||||||
|
func (a *App) IcomSetScopeEdges(lowHz, highHz int64) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetScopeEdges(lowHz, highHz) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomSetRIT sets the RIT/ΔTX offset in signed Hz.
|
||||||
|
func (a *App) IcomSetRIT(hz int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRIT(hz) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomSetRITOn toggles RIT.
|
||||||
|
func (a *App) IcomSetRITOn(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRITOn(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomSetXITOn toggles ΔTX (XIT).
|
||||||
|
func (a *App) IcomSetXITOn(on bool) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetXITOn(on) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomSendCW keys a CW message through the rig's internal keyer (CI-V 0x17).
|
||||||
|
func (a *App) IcomSendCW(text string) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
err := a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SendCW(text) })
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("icom cw: IcomSendCW(%q) failed: %v", text, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomStopCW aborts the CW message currently being sent.
|
||||||
|
func (a *App) IcomStopCW() error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.StopCW() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomSetKeySpeed sets the CW keyer speed in WPM.
|
||||||
|
func (a *App) IcomSetKeySpeed(wpm int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetKeySpeed(wpm) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomSetBreakIn sets CW break-in (0=OFF, 1=SEMI, 2=FULL).
|
||||||
|
func (a *App) IcomSetBreakIn(mode int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetBreakIn(mode) })
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) IcomSetPTT(on bool) error {
|
func (a *App) IcomSetPTT(on bool) error {
|
||||||
if a.cat == nil {
|
if a.cat == nil {
|
||||||
return fmt.Errorf("cat not initialized")
|
return fmt.Errorf("cat not initialized")
|
||||||
@@ -8012,6 +8359,22 @@ func (a *App) FlexSetSplit(on bool) error {
|
|||||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSplit(on) })
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSplit(on) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlexSetActiveSlice focuses a slice (A/B/C/D…) so all commands target it.
|
||||||
|
func (a *App) FlexSetActiveSlice(idx int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetActiveSlice(idx) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexSetTXSlice makes a slice the transmitter (e.g. TX on the active slice).
|
||||||
|
func (a *App) FlexSetTXSlice(idx int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXSlice(idx) })
|
||||||
|
}
|
||||||
|
|
||||||
// keyFlexBandAnt stores the per-band RX/TX antenna map (global, machine-local).
|
// keyFlexBandAnt stores the per-band RX/TX antenna map (global, machine-local).
|
||||||
const keyFlexBandAnt = "flex.band_antennas"
|
const keyFlexBandAnt = "flex.band_antennas"
|
||||||
|
|
||||||
@@ -8226,8 +8589,12 @@ func (a *App) reloadCAT() {
|
|||||||
a.cat.Start(fb)
|
a.cat.Start(fb)
|
||||||
case "icom":
|
case "icom":
|
||||||
// Native Icom CI-V over the radio's USB serial port (local control).
|
// Native Icom CI-V over the radio's USB serial port (local control).
|
||||||
// Same civ protocol a future network backend will reuse for remote.
|
// Same civ protocol the network backend reuses for remote.
|
||||||
a.cat.Start(cat.NewIcomSerial(s.IcomPort, s.IcomBaud, s.IcomAddr, s.DigitalDefault))
|
a.cat.Start(cat.NewIcomSerial(s.IcomPort, s.IcomBaud, s.IcomAddr, s.DigitalDefault))
|
||||||
|
case "icom-net":
|
||||||
|
// Icom CI-V over the rig's built-in LAN server (remote, no RS-BA1 / Remote
|
||||||
|
// Utility). Reuses the whole IcomController over the network transport.
|
||||||
|
a.cat.Start(cat.NewIcomNet(s.IcomNetHost, s.IcomNetUser, s.IcomNetPass, s.IcomAddr, s.DigitalDefault))
|
||||||
case "tci":
|
case "tci":
|
||||||
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
|
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
|
||||||
// TCI-compatible server.
|
// TCI-compatible server.
|
||||||
@@ -8900,6 +9267,7 @@ func (a *App) TestUltrabeam(s UltrabeamSettings) error {
|
|||||||
type AntGeniusSettings struct {
|
type AntGeniusSettings struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
|
Password string `json:"password"` // remote-access password; leave blank on LAN (no AUTH)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAntGeniusSettings returns the persisted Antenna Genius config.
|
// GetAntGeniusSettings returns the persisted Antenna Genius config.
|
||||||
@@ -8908,12 +9276,13 @@ func (a *App) GetAntGeniusSettings() (AntGeniusSettings, error) {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return out, fmt.Errorf("db not initialized")
|
return out, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx, keyAntGeniusEnabled, keyAntGeniusHost)
|
m, err := a.settings.GetMany(a.ctx, keyAntGeniusEnabled, keyAntGeniusHost, keyAntGeniusPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
out.Enabled = m[keyAntGeniusEnabled] == "1"
|
out.Enabled = m[keyAntGeniusEnabled] == "1"
|
||||||
out.Host = m[keyAntGeniusHost]
|
out.Host = m[keyAntGeniusHost]
|
||||||
|
out.Password = m[keyAntGeniusPassword]
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8925,6 +9294,7 @@ func (a *App) SaveAntGeniusSettings(s AntGeniusSettings) error {
|
|||||||
for k, v := range map[string]string{
|
for k, v := range map[string]string{
|
||||||
keyAntGeniusEnabled: boolStr(s.Enabled),
|
keyAntGeniusEnabled: boolStr(s.Enabled),
|
||||||
keyAntGeniusHost: strings.TrimSpace(s.Host),
|
keyAntGeniusHost: strings.TrimSpace(s.Host),
|
||||||
|
keyAntGeniusPassword: s.Password,
|
||||||
} {
|
} {
|
||||||
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
|
||||||
@@ -8946,7 +9316,7 @@ func (a *App) startAntGenius() {
|
|||||||
if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
|
if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.antgenius = antgenius.New(s.Host, 9007)
|
a.antgenius = antgenius.New(s.Host, 9007, s.Password)
|
||||||
_ = a.antgenius.Start()
|
_ = a.antgenius.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9068,7 +9438,7 @@ type WKMacro struct {
|
|||||||
type WinkeyerSettings struct {
|
type WinkeyerSettings struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
winkeyer.Config
|
winkeyer.Config
|
||||||
Engine string `json:"engine"` // keyer backend: "winkeyer" | "tci"
|
Engine string `json:"engine"` // keyer backend: "winkeyer" | "icom" (rig keyer via CI-V) | "tci"
|
||||||
EscClearsCall bool `json:"esc_clears_call"` // ESC also resets the callsign
|
EscClearsCall bool `json:"esc_clears_call"` // ESC also resets the callsign
|
||||||
SendOnType bool `json:"send_on_type"` // key chars live as typed
|
SendOnType bool `json:"send_on_type"` // key chars live as typed
|
||||||
Macros []WKMacro `json:"macros"`
|
Macros []WKMacro `json:"macros"`
|
||||||
|
|||||||
+10
-2
@@ -122,12 +122,20 @@ func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QSLListTemplates returns all saved templates (defaults first).
|
// QSLListTemplates returns the templates for the ACTIVE profile (its own plus any
|
||||||
|
// shared/global ones), defaults first — so the default is per-profile, not a
|
||||||
|
// single global list shared by every profile.
|
||||||
func (a *App) QSLListTemplates() ([]QSLTemplateInfo, error) {
|
func (a *App) QSLListTemplates() ([]QSLTemplateInfo, error) {
|
||||||
if a.qslTemplates == nil {
|
if a.qslTemplates == nil {
|
||||||
return nil, fmt.Errorf("db not initialized")
|
return nil, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
recs, err := a.qslTemplates.List(a.ctx)
|
var recs []qslcard.Record
|
||||||
|
var err error
|
||||||
|
if p, e := a.profiles.Active(a.ctx); e == nil {
|
||||||
|
recs, err = a.qslTemplates.ListFor(a.ctx, p.ID)
|
||||||
|
} else {
|
||||||
|
recs, err = a.qslTemplates.List(a.ctx)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
// Command icomnettest is an iteration probe for the Icom IP remote protocol —
|
||||||
|
// the LAN server built into the IC-7610 that the Icom "Remote Utility" (and
|
||||||
|
// wfview) talk to. OpsLog reimplements this directly so it can BE both the
|
||||||
|
// Remote Utility (Ethernet ↔ radio) and the logger/CAT client, dropping the
|
||||||
|
// virtual-COM + RS-BA1 chain entirely.
|
||||||
|
//
|
||||||
|
// This probe drives TWO streams and hex-dumps everything:
|
||||||
|
// Control (UDP 50001): handshake → login → token [VERIFIED on the real rig]
|
||||||
|
// CI-V (UDP 50002): handshake → openClose(open) → send CI-V read-freq
|
||||||
|
// (FE FE 98 E0 03 FD) → print the rig's reply.
|
||||||
|
// Framing (passcode table, packet offsets, CI-V data_packet, openclose) is
|
||||||
|
// reimplemented from the public wfview protocol and verified byte-for-byte
|
||||||
|
// against real Remote-Utility captures (build/bin/civ*.pcapng). No GPLv3 code.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// go run ./cmd/icomnettest <rig-ip> <user> <pass> [compname]
|
||||||
|
//
|
||||||
|
// SAFE: read-only CI-V (operating frequency). No TX, no writes.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var le = binary.LittleEndian
|
||||||
|
var be = binary.BigEndian
|
||||||
|
|
||||||
|
// passcodeSeq — Icom's obfuscation table (values live at index 0x20..0x7e).
|
||||||
|
// VERIFIED: user "f6bgc" → 3F 65 50 25 55 (matches the capture).
|
||||||
|
var passcodeSeq = [256]byte{
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0x47, 0x5d, 0x4c, 0x42, 0x66, 0x20, 0x23, 0x46, 0x4e, 0x57, 0x45, 0x3d, 0x67, 0x76, 0x60, 0x41, 0x62, 0x39, 0x59, 0x2d, 0x68, 0x7e,
|
||||||
|
0x7c, 0x65, 0x7d, 0x49, 0x29, 0x72, 0x73, 0x78, 0x21, 0x6e, 0x5a, 0x5e, 0x4a, 0x3e, 0x71, 0x2c, 0x2a, 0x54, 0x3c, 0x3a, 0x63, 0x4f,
|
||||||
|
0x43, 0x75, 0x27, 0x79, 0x5b, 0x35, 0x70, 0x48, 0x6b, 0x56, 0x6f, 0x34, 0x32, 0x6c, 0x30, 0x61, 0x6d, 0x7b, 0x2f, 0x4b, 0x64, 0x38,
|
||||||
|
0x2b, 0x2e, 0x50, 0x40, 0x3f, 0x55, 0x33, 0x37, 0x25, 0x77, 0x24, 0x26, 0x74, 0x6a, 0x28, 0x53, 0x4d, 0x69, 0x22, 0x5c, 0x44, 0x31,
|
||||||
|
0x36, 0x58, 0x3b, 0x7a, 0x51, 0x5f, 0x52,
|
||||||
|
}
|
||||||
|
|
||||||
|
func passcode(s string) []byte {
|
||||||
|
out := make([]byte, 0, len(s))
|
||||||
|
for i := 0; i < len(s) && i < 16; i++ {
|
||||||
|
p := int(s[i]) + i
|
||||||
|
if p > 126 {
|
||||||
|
p = 32 + p%127
|
||||||
|
}
|
||||||
|
out = append(out, passcodeSeq[p])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- packet builders (offsets verified vs wfview structs + real captures) ---
|
||||||
|
|
||||||
|
func ctrlPacket(typ, seq uint16, sentid, rcvdid uint32) []byte {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
le.PutUint32(b[0:], 0x10)
|
||||||
|
le.PutUint16(b[4:], typ)
|
||||||
|
le.PutUint16(b[6:], seq)
|
||||||
|
le.PutUint32(b[8:], sentid)
|
||||||
|
le.PutUint32(b[12:], rcvdid)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildLogin(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user, pass, name string) []byte {
|
||||||
|
b := make([]byte, 0x80)
|
||||||
|
le.PutUint32(b[0:], 0x80)
|
||||||
|
le.PutUint16(b[6:], seq)
|
||||||
|
le.PutUint32(b[8:], sentid)
|
||||||
|
le.PutUint32(b[12:], rcvdid)
|
||||||
|
be.PutUint32(b[0x10:], 0x80-0x10)
|
||||||
|
b[0x14] = 0x01 // requestreply
|
||||||
|
b[0x15] = 0x00 // requesttype = login
|
||||||
|
be.PutUint16(b[0x16:], innerSeq)
|
||||||
|
le.PutUint16(b[0x1a:], tokReq)
|
||||||
|
le.PutUint32(b[0x1c:], token)
|
||||||
|
copy(b[0x40:0x50], passcode(user))
|
||||||
|
copy(b[0x50:0x60], passcode(pass))
|
||||||
|
nm := name
|
||||||
|
if len(nm) > 16 {
|
||||||
|
nm = nm[:16]
|
||||||
|
}
|
||||||
|
copy(b[0x60:0x70], []byte(nm))
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildToken(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) []byte {
|
||||||
|
b := make([]byte, 0x40)
|
||||||
|
le.PutUint32(b[0:], 0x40)
|
||||||
|
le.PutUint16(b[6:], seq)
|
||||||
|
le.PutUint32(b[8:], sentid)
|
||||||
|
le.PutUint32(b[12:], rcvdid)
|
||||||
|
be.PutUint32(b[0x10:], 0x40-0x10)
|
||||||
|
b[0x14] = 0x01 // requestreply
|
||||||
|
b[0x15] = 0x02 // requesttype = token
|
||||||
|
be.PutUint16(b[0x16:], innerSeq)
|
||||||
|
le.PutUint16(b[0x1a:], tokReq)
|
||||||
|
le.PutUint32(b[0x1c:], token)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildConnInfo — 144-byte sendRequestStream on the CONTROL stream. Tells the
|
||||||
|
// rig to route the CI-V/audio streams to the authenticated session and which
|
||||||
|
// local ports we use. Values verified byte-for-byte vs a real Remote-Utility
|
||||||
|
// capture (civ4): requesttype=0x03, commoncap=0x8010, the rig's MAC echoed,
|
||||||
|
// name "IC-7610", scrambled username, rxenable=0 (audio off — CI-V only),
|
||||||
|
// rxcodec 0x10 / txcodec 0x04, rxsample 16000 / txsample 8000 (BE), civport /
|
||||||
|
// audioport (BE), txbuffer 100.
|
||||||
|
func buildConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16) []byte {
|
||||||
|
b := make([]byte, 0x90)
|
||||||
|
le.PutUint32(b[0:], 0x90)
|
||||||
|
le.PutUint16(b[6:], seq)
|
||||||
|
le.PutUint32(b[8:], sentid)
|
||||||
|
le.PutUint32(b[12:], rcvdid)
|
||||||
|
be.PutUint32(b[0x10:], 0x90-0x10)
|
||||||
|
b[0x14] = 0x01 // requestreply
|
||||||
|
b[0x15] = 0x03 // requesttype = conninfo / open streams
|
||||||
|
be.PutUint16(b[0x16:], innerSeq)
|
||||||
|
le.PutUint16(b[0x1a:], tokReq)
|
||||||
|
le.PutUint32(b[0x1c:], token)
|
||||||
|
le.PutUint16(b[0x27:], 0x8010) // commoncap
|
||||||
|
copy(b[0x2a:0x30], rigMAC) // macaddress (the rig's, echoed back)
|
||||||
|
copy(b[0x40:0x60], []byte("IC-7610"))
|
||||||
|
copy(b[0x60:0x70], passcode(user))
|
||||||
|
b[0x70] = 0x00 // rxenable (0 = audio off)
|
||||||
|
b[0x71] = 0x00 // txenable
|
||||||
|
b[0x72] = 0x10 // rxcodec
|
||||||
|
b[0x73] = 0x04 // txcodec
|
||||||
|
be.PutUint32(b[0x74:], 16000) // rxsample
|
||||||
|
be.PutUint32(b[0x78:], 8000) // txsample
|
||||||
|
be.PutUint32(b[0x7c:], uint32(civPort))
|
||||||
|
be.PutUint32(b[0x80:], uint32(audioPort))
|
||||||
|
be.PutUint32(b[0x84:], 100) // txbuffer
|
||||||
|
b[0x88] = 0x00 // convert
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOpenClose — 22-byte start/stop for the CI-V stream. magic 0x04=open,
|
||||||
|
// 0x00=close. data=0x01c0 (@0x10), civSeq (BE @0x13), magic (@0x15).
|
||||||
|
func buildOpenClose(seq uint16, sentid, rcvdid uint32, civSeq uint16, magic byte) []byte {
|
||||||
|
b := make([]byte, 0x16)
|
||||||
|
le.PutUint32(b[0:], 0x16)
|
||||||
|
le.PutUint16(b[6:], seq)
|
||||||
|
le.PutUint32(b[8:], sentid)
|
||||||
|
le.PutUint32(b[12:], rcvdid)
|
||||||
|
le.PutUint16(b[0x10:], 0x01c0)
|
||||||
|
be.PutUint16(b[0x13:], civSeq)
|
||||||
|
b[0x15] = magic
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCivData — wraps raw CI-V bytes: 21-byte header (reply 0xc1 @0x10,
|
||||||
|
// datalen LE @0x11, civSeq BE @0x13) + CI-V frame @0x15.
|
||||||
|
func buildCivData(seq uint16, sentid, rcvdid uint32, civSeq uint16, civ []byte) []byte {
|
||||||
|
n := 0x15 + len(civ)
|
||||||
|
b := make([]byte, n)
|
||||||
|
le.PutUint32(b[0:], uint32(n))
|
||||||
|
le.PutUint16(b[6:], seq)
|
||||||
|
le.PutUint32(b[8:], sentid)
|
||||||
|
le.PutUint32(b[12:], rcvdid)
|
||||||
|
b[0x10] = 0xc1
|
||||||
|
le.PutUint16(b[0x11:], uint16(len(civ)))
|
||||||
|
be.PutUint16(b[0x13:], civSeq)
|
||||||
|
copy(b[0x15:], civ)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func header(b []byte) (length uint32, typ, seq uint16, sentid, rcvdid uint32, ok bool) {
|
||||||
|
if len(b) < 16 {
|
||||||
|
return 0, 0, 0, 0, 0, false
|
||||||
|
}
|
||||||
|
return le.Uint32(b[0:]), le.Uint16(b[4:]), le.Uint16(b[6:]), le.Uint32(b[8:]), le.Uint32(b[12:]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func localID(conn net.Conn) uint32 {
|
||||||
|
a := conn.LocalAddr().(*net.UDPAddr)
|
||||||
|
return uint32(a.IP.To4()[0])<<24 | uint32(a.IP.To4()[1])<<16 | uint32(uint16(a.Port))
|
||||||
|
}
|
||||||
|
|
||||||
|
func recv(conn net.Conn, ms int, buf []byte) ([]byte, bool) {
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(time.Duration(ms) * time.Millisecond))
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return append([]byte(nil), buf[:n]...), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func dump(tag string, p []byte) { fmt.Printf("%s (%d)\n%s\n", tag, len(p), hex.Dump(p)) }
|
||||||
|
|
||||||
|
// pingReply mirrors a ping, swaps ids, sets the reply flag at offset 16.
|
||||||
|
func pingReply(pkt []byte, myID, remoteID uint32) []byte {
|
||||||
|
r := append([]byte(nil), pkt...)
|
||||||
|
if len(r) >= 17 {
|
||||||
|
le.PutUint32(r[8:], myID)
|
||||||
|
le.PutUint32(r[12:], remoteID)
|
||||||
|
r[16] = 0x01
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// handshake: areYouThere(seq0) → iAmHere → areYouReady(seq1) → iAmReady.
|
||||||
|
// Returns the rig's remote id. Replies to any pings meanwhile.
|
||||||
|
func handshake(conn net.Conn, myID uint32, label string) (uint32, bool) {
|
||||||
|
buf := make([]byte, 2048)
|
||||||
|
conn.Write(ctrlPacket(0x03, 0, myID, 0)) // areYouThere
|
||||||
|
fmt.Printf("[%s] TX areYouThere\n", label)
|
||||||
|
var remoteID uint32
|
||||||
|
deadline := time.Now().Add(4 * time.Second)
|
||||||
|
lastTry := time.Now()
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
p, ok := recv(conn, 200, buf)
|
||||||
|
if !ok {
|
||||||
|
if remoteID == 0 && time.Since(lastTry) > 500*time.Millisecond {
|
||||||
|
conn.Write(ctrlPacket(0x03, 0, myID, 0))
|
||||||
|
lastTry = time.Now()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, typ, _, sentid, _, ok := header(p)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch typ {
|
||||||
|
case 0x04: // iAmHere
|
||||||
|
remoteID = sentid
|
||||||
|
fmt.Printf("[%s] iAmHere remoteID=0x%08X → TX areYouReady\n", label, remoteID)
|
||||||
|
conn.Write(ctrlPacket(0x06, 1, myID, remoteID))
|
||||||
|
case 0x06: // iAmReady
|
||||||
|
if remoteID != 0 {
|
||||||
|
fmt.Printf("[%s] iAmReady — link up ✓\n", label)
|
||||||
|
return remoteID, true
|
||||||
|
}
|
||||||
|
case 0x07: // ping
|
||||||
|
conn.Write(pingReply(p, myID, remoteID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remoteID, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 4 {
|
||||||
|
fmt.Println("usage: icomnettest <rig-ip> <user> <pass> [compname]")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
ip, user, pass := os.Args[1], os.Args[2], os.Args[3]
|
||||||
|
compName := "OpsLog"
|
||||||
|
if len(os.Args) >= 5 {
|
||||||
|
compName = os.Args[4]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== CONTROL STREAM (50001) =====================
|
||||||
|
ctrl, err := net.Dial("udp4", net.JoinHostPort(ip, "50001"))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("dial control: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer ctrl.Close()
|
||||||
|
cID := localID(ctrl)
|
||||||
|
fmt.Printf("=== CONTROL 50001 (myID=0x%08X) ===\n", cID)
|
||||||
|
fmt.Printf("scrambled user=% X pass=% X\n\n", passcode(user), passcode(pass))
|
||||||
|
|
||||||
|
cRemote, ok := handshake(ctrl, cID, "ctrl")
|
||||||
|
if !ok {
|
||||||
|
fmt.Println("control handshake failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// login → token
|
||||||
|
var cTracked uint16 = 1
|
||||||
|
var cInner uint16 = 1
|
||||||
|
tokReq := uint16(0x0c77)
|
||||||
|
dump("[ctrl] TX login", buildLogin(cTracked, cInner, tokReq, cID, cRemote, 0, user, pass, compName))
|
||||||
|
ctrl.Write(buildLogin(cTracked, cInner, tokReq, cID, cRemote, 0, user, pass, compName))
|
||||||
|
cTracked++
|
||||||
|
cInner++
|
||||||
|
|
||||||
|
var token uint32
|
||||||
|
buf := make([]byte, 2048)
|
||||||
|
deadline := time.Now().Add(4 * time.Second)
|
||||||
|
for token == 0 && time.Now().Before(deadline) {
|
||||||
|
p, ok := recv(ctrl, 200, buf)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
length, typ, _, _, _, _ := header(p)
|
||||||
|
if typ == 0x00 && length == 0x60 && len(p) >= 0x34 { // login response
|
||||||
|
token = le.Uint32(p[0x1c:])
|
||||||
|
errCode := le.Uint32(p[0x30:])
|
||||||
|
if errCode != 0 || token == 0 {
|
||||||
|
fmt.Printf(">> LOGIN REJECTED err=0x%08X token=0x%08X\n", errCode, token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(">> LOGIN OK ✓ token=0x%08X\n", token)
|
||||||
|
ctrl.Write(buildToken(cTracked, cInner, tokReq, cID, cRemote, token))
|
||||||
|
cTracked++
|
||||||
|
cInner++
|
||||||
|
} else if typ == 0x07 {
|
||||||
|
ctrl.Write(pingReply(p, cID, cRemote))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if token == 0 {
|
||||||
|
fmt.Println("no token — login not accepted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send conninfo on the control stream — routes the CI-V stream to this
|
||||||
|
// authenticated session and announces our civ/audio local ports (50002/3).
|
||||||
|
rigMAC := []byte{0x00, 0x90, 0xc7, 0x09, 0xba, 0x3f} // F6BGC's IC-7610 (from the caps packet)
|
||||||
|
dump("[ctrl] TX conninfo", buildConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003))
|
||||||
|
ctrl.Write(buildConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003))
|
||||||
|
cTracked++
|
||||||
|
cInner++
|
||||||
|
// Let the rig's caps/conninfo replies flow for ~600ms (reply to pings).
|
||||||
|
drainEnd := time.Now().Add(600 * time.Millisecond)
|
||||||
|
for time.Now().Before(drainEnd) {
|
||||||
|
if p, ok := recv(ctrl, 100, buf); ok {
|
||||||
|
if _, typ, _, _, _, _ := header(p); typ == 0x07 {
|
||||||
|
ctrl.Write(pingReply(p, cID, cRemote))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== CI-V STREAM (50002) =====================
|
||||||
|
// Bind our civ socket to LOCAL port 50002 (= the civport announced above),
|
||||||
|
// as the Remote Utility does. Requires the Remote Utility to be CLOSED.
|
||||||
|
civ, err := net.DialUDP("udp4", &net.UDPAddr{Port: 50002}, &net.UDPAddr{IP: net.ParseIP(ip), Port: 50002})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("dial civ (local :50002 — is the Remote Utility still running?): %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer civ.Close()
|
||||||
|
vID := localID(civ)
|
||||||
|
fmt.Printf("\n=== CI-V 50002 (myID=0x%08X) ===\n", vID)
|
||||||
|
vRemote, ok := handshake(civ, vID, "civ")
|
||||||
|
if !ok {
|
||||||
|
fmt.Println("CI-V handshake failed (may need the conninfo packet on control first)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var vTracked uint16 = 1 // outer tracked seq @0x06
|
||||||
|
var vCivSeq uint16 = 1 // inner CI-V seq @0x13 (BE)
|
||||||
|
// openClose(open) starts CI-V data flow.
|
||||||
|
dump("[civ] TX openClose(open)", buildOpenClose(vTracked, vID, vRemote, vCivSeq, 0x04))
|
||||||
|
civ.Write(buildOpenClose(vTracked, vID, vRemote, vCivSeq, 0x04))
|
||||||
|
vTracked++
|
||||||
|
vCivSeq++
|
||||||
|
|
||||||
|
// Try several read commands, spaced out. Some rigs NG the basic 0x03 read
|
||||||
|
// over the network tunnel; 0x25 / 0x04 and unsolicited transceive frames
|
||||||
|
// (sent when you turn the VFO) still work. The tunnel itself is proven, so
|
||||||
|
// this figures out which read the rig actually answers.
|
||||||
|
sendCiv := func(name string, f []byte) {
|
||||||
|
fmt.Printf("[civ] TX %s\n", name)
|
||||||
|
civ.Write(buildCivData(vTracked, vID, vRemote, vCivSeq, f))
|
||||||
|
vTracked++
|
||||||
|
vCivSeq++
|
||||||
|
}
|
||||||
|
// The rig is in STANDBY (network up, radio off) — it NG's every command
|
||||||
|
// until powered on via CI-V. Send power-on (0x18 0x01, with an FE wake
|
||||||
|
// preamble, as the Remote Utility does), then poll read-freq while it boots.
|
||||||
|
powerOn := make([]byte, 0, 32)
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
powerOn = append(powerOn, 0xFE)
|
||||||
|
}
|
||||||
|
powerOn = append(powerOn, 0xFE, 0xFE, 0x98, 0xE0, 0x18, 0x01, 0xFD)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
sendCiv("POWER ON (0x18 01)", powerOn)
|
||||||
|
fmt.Print("\n>>> rig booting (~10-15 s) — polling read-freq until it answers <<<\n\n")
|
||||||
|
readFreq := []byte{0xFE, 0xFE, 0x98, 0xE0, 0x03, 0xFD}
|
||||||
|
|
||||||
|
cbuf := make([]byte, 4096)
|
||||||
|
vbuf := make([]byte, 4096)
|
||||||
|
end := time.Now().Add(30 * time.Second)
|
||||||
|
lastIdleC, lastIdleV, lastCmd := time.Now(), time.Now(), time.Now()
|
||||||
|
for time.Now().Before(end) {
|
||||||
|
if p, ok := recv(ctrl, 40, cbuf); ok {
|
||||||
|
if _, typ, _, _, _, _ := header(p); typ == 0x07 {
|
||||||
|
ctrl.Write(pingReply(p, cID, cRemote))
|
||||||
|
}
|
||||||
|
} else if time.Since(lastIdleC) > 200*time.Millisecond {
|
||||||
|
ctrl.Write(ctrlPacket(0x00, 0, cID, cRemote))
|
||||||
|
lastIdleC = time.Now()
|
||||||
|
}
|
||||||
|
if p, ok := recv(civ, 40, vbuf); ok {
|
||||||
|
_, typ, _, _, _, _ := header(p)
|
||||||
|
if typ == 0x07 {
|
||||||
|
civ.Write(pingReply(p, vID, vRemote))
|
||||||
|
} else if typ == 0x00 && len(p) > 0x15 && p[0x10] == 0xc1 {
|
||||||
|
f := p[0x15:]
|
||||||
|
if d := decodeCiv(f); d != "" {
|
||||||
|
fmt.Printf(">> CI-V RX: % X %s\n", f, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if time.Since(lastIdleV) > 200*time.Millisecond {
|
||||||
|
civ.Write(ctrlPacket(0x00, 0, vID, vRemote))
|
||||||
|
lastIdleV = time.Now()
|
||||||
|
}
|
||||||
|
if time.Since(lastCmd) > 1000*time.Millisecond {
|
||||||
|
sendCiv("read-freq 0x03", readFreq)
|
||||||
|
lastCmd = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean close.
|
||||||
|
civ.Write(buildOpenClose(vTracked, vID, vRemote, vCivSeq, 0x00)) // openClose(close)
|
||||||
|
civ.Write(ctrlPacket(0x05, 0, vID, vRemote)) // disconnect
|
||||||
|
ctrl.Write(ctrlPacket(0x05, 0, cID, cRemote))
|
||||||
|
fmt.Println("\nDone. Look for '>> CI-V RX:' and 'FREQUENCY reply'.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeCiv describes a received CI-V frame (FE FE <to> <from> <cmd> … FD).
|
||||||
|
// Only frames FROM the rig (from=0x98) are interesting; our own echoed commands
|
||||||
|
// (from=0xE0) return "" so they're not printed.
|
||||||
|
func decodeCiv(f []byte) string {
|
||||||
|
if len(f) < 6 || f[0] != 0xFE || f[1] != 0xFE {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if f[3] != 0x98 { // not from the rig (our echoed command) — skip
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cmd := f[4]
|
||||||
|
body := f[5 : len(f)-1] // between cmd and the trailing FD
|
||||||
|
switch cmd {
|
||||||
|
case 0xFA:
|
||||||
|
return "NG (command rejected)"
|
||||||
|
case 0xFB:
|
||||||
|
return "OK (ack)"
|
||||||
|
case 0x00, 0x03, 0x05: // (transceive) freq / read-freq
|
||||||
|
if len(body) >= 5 {
|
||||||
|
return "FREQ " + decodeFreq(body[:5])
|
||||||
|
}
|
||||||
|
case 0x25: // read/set VFO freq (body = subcmd + 5 BCD)
|
||||||
|
if len(body) >= 6 {
|
||||||
|
return fmt.Sprintf("VFO%d FREQ %s", body[0], decodeFreq(body[1:6]))
|
||||||
|
}
|
||||||
|
case 0x01, 0x04: // (transceive) mode / read-mode
|
||||||
|
if len(body) >= 1 {
|
||||||
|
return fmt.Sprintf("MODE 0x%02X filt 0x%02X", body[0], lastOr(body, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("cmd 0x%02X", cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastOr(b []byte, i int) byte {
|
||||||
|
if i < len(b) {
|
||||||
|
return b[i]
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeFreq turns Icom little-endian BCD (5 bytes) into a MHz string.
|
||||||
|
func decodeFreq(bcd []byte) string {
|
||||||
|
var hz uint64
|
||||||
|
mul := uint64(1)
|
||||||
|
for _, b := range bcd {
|
||||||
|
hz += uint64(b&0x0f) * mul
|
||||||
|
mul *= 10
|
||||||
|
hz += uint64(b>>4) * mul
|
||||||
|
mul *= 10
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.6f MHz", float64(hz)/1e6)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Command psttest probes PstRotatorAz's UDP tracking input to discover the wire
|
||||||
|
// format it expects for the "DXLog.net" tracker. We don't have the spec, so this
|
||||||
|
// sends a series of labelled candidate datagrams to the target and pauses between
|
||||||
|
// each so you can watch PstRotatorAz and note which one it reacts to (the band
|
||||||
|
// filter "Rotate Antenna only for the Selected Bands" changes, or the frequency
|
||||||
|
// shows in the tracker box).
|
||||||
|
//
|
||||||
|
// Setup on the PstRotatorAz side:
|
||||||
|
// - Tracker = DXLog.net
|
||||||
|
// - Note the UDP port it listens on (commonly 12040) and this PC's IP
|
||||||
|
// - In the tracker setup tick a couple of bands so the band filter is visible
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run ./cmd/psttest 192.168.1.50:12040 # default 14025.5 kHz
|
||||||
|
// go run ./cmd/psttest 192.168.1.50:12040 21074 # a specific kHz
|
||||||
|
// go run ./cmd/psttest 192.168.1.50:12040 21074 3 # repeat each variant 3x
|
||||||
|
//
|
||||||
|
// Watch PstRotatorAz as it runs; when one variant makes it react, that's the
|
||||||
|
// format — tell me the variant number and I'll wire it into OpsLog.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type variant struct {
|
||||||
|
name string
|
||||||
|
// build returns the datagram for a given frequency in kHz.
|
||||||
|
build func(khz float64) string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
fmt.Println("usage: psttest <ip:port> [freqKHz] [repeat]")
|
||||||
|
fmt.Println("example: psttest 192.168.1.50:12040 3525.5")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
target := os.Args[1]
|
||||||
|
khz := 3525.5
|
||||||
|
if len(os.Args) >= 3 {
|
||||||
|
if v, err := strconv.ParseFloat(os.Args[2], 64); err == nil {
|
||||||
|
khz = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repeat := 1
|
||||||
|
if len(os.Args) >= 4 {
|
||||||
|
if v, err := strconv.Atoi(os.Args[3]); err == nil && v > 0 {
|
||||||
|
repeat = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hz := int64(khz*1000 + 0.5)
|
||||||
|
tensHz := hz / 10 // N1MM/DXLog <Freq> unit
|
||||||
|
|
||||||
|
// Tag <PST><FREQUENCY> is confirmed. PstRotator strips the decimal point and
|
||||||
|
// reads the digits, so pin down the exact unit: watch which one shows the real
|
||||||
|
// frequency (3.5255 MHz), not 0.35255 / 0.003525 / 0.035255.
|
||||||
|
variants := []variant{
|
||||||
|
{"FREQUENCY = Hz integer (expect 3.5255 MHz) ← prediction", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%d</FREQUENCY></PST>", hz) }},
|
||||||
|
{"FREQUENCY = tens-of-Hz (expect 0.35255 MHz)", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%d</FREQUENCY></PST>", tensHz) }},
|
||||||
|
{"FREQUENCY = kHz integer (expect 0.003525 MHz)", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%d</FREQUENCY></PST>", int64(k+0.5)) }},
|
||||||
|
{"FREQUENCY = kHz decimal (baseline: gave 0.035255 MHz)", func(k float64) string { return fmt.Sprintf("<PST><FREQUENCY>%.1f</FREQUENCY></PST>", k) }},
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.Dial("udp", target)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("dial %s: %v\n", target, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
fmt.Printf("Sending %d variants to %s at %.1f kHz (%d Hz, %d tens-of-Hz), %dx each.\n\n", len(variants), target, khz, hz, tensHz, repeat)
|
||||||
|
for i, v := range variants {
|
||||||
|
msg := v.build(khz)
|
||||||
|
fmt.Printf("── Variant %d: %s\n", i+1, v.name)
|
||||||
|
fmt.Printf(" payload: %s\n", msg)
|
||||||
|
for r := 0; r < repeat; r++ {
|
||||||
|
if _, err := conn.Write([]byte(msg)); err != nil {
|
||||||
|
fmt.Printf(" write error: %v\n", err)
|
||||||
|
}
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
fmt.Printf(" → sent. Watch PstRotatorAz for ~4s…\n\n")
|
||||||
|
time.Sleep(4 * time.Second)
|
||||||
|
}
|
||||||
|
fmt.Println("Done. Which variant number made PstRotatorAz react?")
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
$DB_HOST = '10.10.10.15'; // your MySQL host (same as OpsLog's logbook)
|
$DB_HOST = '10.10.10.15'; // your MySQL host (same as OpsLog's logbook)
|
||||||
|
$DB_PORT = 3306; // MySQL port — change if your server listens elsewhere
|
||||||
$DB_NAME = 'opslog'; // database name
|
$DB_NAME = 'opslog'; // database name
|
||||||
$DB_USER = 'opslog';
|
$DB_USER = 'opslog';
|
||||||
$DB_PASS = 'CHANGE_ME';
|
$DB_PASS = 'CHANGE_ME';
|
||||||
@@ -27,7 +28,7 @@ $STALE_SECONDS = 120; // an operator is "active" if seen within this wi
|
|||||||
// instead of a fatal "table doesn't exist".
|
// instead of a fatal "table doesn't exist".
|
||||||
mysqli_report(MYSQLI_REPORT_OFF);
|
mysqli_report(MYSQLI_REPORT_OFF);
|
||||||
|
|
||||||
$mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME);
|
$mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME, $DB_PORT);
|
||||||
if ($mysqli->connect_errno) {
|
if ($mysqli->connect_errno) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
exit('DB error');
|
exit('DB error');
|
||||||
|
|||||||
+294
-113
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
AlertCircle, Antenna, Bell, CheckCircle2, Clock, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
||||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap,
|
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
ListCountries,
|
ListCountries,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
||||||
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
||||||
|
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
||||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||||
@@ -323,7 +324,7 @@ export default function App() {
|
|||||||
title={`${on ? 'Unlock' : 'Lock'} ${title}`}
|
title={`${on ? 'Unlock' : 'Lock'} ${title}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center justify-center size-3.5 rounded transition-colors',
|
'inline-flex items-center justify-center size-3.5 rounded transition-colors',
|
||||||
on ? 'text-amber-600 hover:text-amber-700' : 'text-muted-foreground/40 hover:text-muted-foreground',
|
on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="size-3" />
|
<Icon className="size-3" />
|
||||||
@@ -590,7 +591,7 @@ export default function App() {
|
|||||||
// Contest session: load once, then persist on every change (merge + save).
|
// Contest session: load once, then persist on every change (merge + save).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
GetUIPref('opslog.contest').then((s) => {
|
GetUIPref('opslog.contest').then((s) => {
|
||||||
if (s) { try { setContest({ ...CONTEST_DEFAULT, ...JSON.parse(s) }); } catch {} }
|
if (s) { try { const c = { ...CONTEST_DEFAULT, ...JSON.parse(s) }; setContest(c); if (c.active) setContestTabEnabled(true); } catch {} }
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
const updateContest = (patch: Partial<ContestSession>) => {
|
const updateContest = (patch: Partial<ContestSession>) => {
|
||||||
@@ -663,6 +664,24 @@ export default function App() {
|
|||||||
const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits
|
const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits
|
||||||
const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign
|
const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign
|
||||||
const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed
|
const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed
|
||||||
|
// CW keyer output engine (persisted in the WinKeyer settings, chosen in
|
||||||
|
// Settings → CW Keyer): the WinKeyer hardware, or the Icom rig's own keyer via
|
||||||
|
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
||||||
|
// auto-call and <LOGQSO> are shared; only the transport differs.
|
||||||
|
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
|
||||||
|
const cwSource: 'winkeyer' | 'icom' = wkEngine === 'icom' ? 'icom' : 'winkeyer';
|
||||||
|
const cwSourceRef = useRef(cwSource);
|
||||||
|
useEffect(() => { cwSourceRef.current = cwSource; }, [cwSource]);
|
||||||
|
// CW break-in (0=OFF, 1=SEMI, 2=FULL) — must be on for the rig's 0x17 keyer to
|
||||||
|
// actually transmit. Exposed in the CW keyer panel when the engine is Icom.
|
||||||
|
const [icomBreakIn, setIcomBreakIn] = useState(0);
|
||||||
|
const setBreakIn = useCallback((m: number) => { setIcomBreakIn(m); IcomSetBreakIn(m).catch(() => {}); }, []);
|
||||||
|
// Seed the current break-in from the rig when the CW panel becomes active in
|
||||||
|
// Icom mode (so the control reflects the radio's real state).
|
||||||
|
useEffect(() => {
|
||||||
|
if (cwSource !== 'icom' || !wkEnabled || !(catState.backend === 'icom' && catState.connected)) return;
|
||||||
|
GetIcomState().then((s: any) => { if (s && typeof s.break_in === 'number') setIcomBreakIn(s.break_in); }).catch(() => {});
|
||||||
|
}, [cwSource, wkEnabled, catState.backend, catState.connected]);
|
||||||
// Auto-call: repeat the clicked macro (e.g. F1 CQ) every (message + N seconds)
|
// Auto-call: repeat the clicked macro (e.g. F1 CQ) every (message + N seconds)
|
||||||
// until a reply is entered or it's stopped. Persisted as UI prefs.
|
// until a reply is entered or it's stopped. Persisted as UI prefs.
|
||||||
const [wkAutoCall, setWkAutoCall] = useState(() => localStorage.getItem('opslog.wkAutoCall') === '1');
|
const [wkAutoCall, setWkAutoCall] = useState(() => localStorage.getItem('opslog.wkAutoCall') === '1');
|
||||||
@@ -678,7 +697,10 @@ export default function App() {
|
|||||||
const wkEscClearsRef = useRef(true);
|
const wkEscClearsRef = useRef(true);
|
||||||
const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log
|
const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log
|
||||||
useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]);
|
useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]);
|
||||||
useEffect(() => { wkActiveRef.current = wkEnabled && wkStatus.connected; }, [wkEnabled, wkStatus.connected]);
|
useEffect(() => {
|
||||||
|
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected) : wkStatus.connected;
|
||||||
|
wkActiveRef.current = wkEnabled && connected;
|
||||||
|
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
|
||||||
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
|
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
|
||||||
|
|
||||||
// === Digital Voice Keyer (DVK) ===
|
// === Digital Voice Keyer (DVK) ===
|
||||||
@@ -771,9 +793,28 @@ export default function App() {
|
|||||||
|
|
||||||
const [alertsOpen, setAlertsOpen] = useState(false); // Alert management modal
|
const [alertsOpen, setAlertsOpen] = useState(false); // Alert management modal
|
||||||
|
|
||||||
|
// Persistent spot-alert tray: keep the last few fired alerts visible (they used
|
||||||
|
// to vanish with the 3 s toast, lost if you were on another window). Each stays
|
||||||
|
// ~10 min and is clickable to tune the rig to its freq/mode.
|
||||||
|
type RecentAlert = { id: number; rule: string; call: string; band: string; mode: string; freq_hz: number; country: string; comment: string; at: number };
|
||||||
|
const [recentAlerts, setRecentAlerts] = useState<RecentAlert[]>([]);
|
||||||
|
const [alertsPanelOpen, setAlertsPanelOpen] = useState(false); // LED dropdown
|
||||||
|
const ALERT_TTL_MS = 10 * 60 * 1000;
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
setRecentAlerts((prev) => { const cut = Date.now() - ALERT_TTL_MS; const n = prev.filter((a) => a.at >= cut); return n.length === prev.length ? prev : n; });
|
||||||
|
}, 20000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
// Close the (now-hidden) dropdown when the last alert clears/expires.
|
||||||
|
useEffect(() => { if (recentAlerts.length === 0) setAlertsPanelOpen(false); }, [recentAlerts.length]);
|
||||||
|
|
||||||
// NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
|
// NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
|
||||||
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
|
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
|
||||||
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
|
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
|
||||||
|
// Contest tab is hidden until enabled from Tools → Contest mode.
|
||||||
|
const [contestTabEnabled, setContestTabEnabled] = useState(() => localStorage.getItem('opslog.contestTab') === '1');
|
||||||
|
useEffect(() => { localStorage.setItem('opslog.contestTab', contestTabEnabled ? '1' : '0'); }, [contestTabEnabled]);
|
||||||
|
|
||||||
const [dvkEnabled, setDvkEnabled] = useState(false);
|
const [dvkEnabled, setDvkEnabled] = useState(false);
|
||||||
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||||||
@@ -836,6 +877,12 @@ export default function App() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
// Global band-map-tab options (apply to every map in the tab): hide FTx/digital
|
||||||
|
// spots, and fit each map to its full band span.
|
||||||
|
const [bandMapHideFt, setBandMapHideFt] = useState(() => localStorage.getItem('opslog.bandMapHideFt') === '1');
|
||||||
|
const [bandMapFit, setBandMapFit] = useState(() => localStorage.getItem('opslog.bandMapFit') === '1');
|
||||||
|
const toggleBandMapHideFt = useCallback(() => setBandMapHideFt((v) => { const n = !v; writeUiPref('opslog.bandMapHideFt', n ? '1' : '0'); return n; }), []);
|
||||||
|
const toggleBandMapFit = useCallback(() => setBandMapFit((v) => { const n = !v; writeUiPref('opslog.bandMapFit', n ? '1' : '0'); return n; }), []);
|
||||||
// Single band map docked beside the table (toggled by the toolbar button,
|
// Single band map docked beside the table (toggled by the toolbar button,
|
||||||
// visible across tabs). Independent of the multi-band "Band Map" tab.
|
// visible across tabs). Independent of the multi-band "Band Map" tab.
|
||||||
const [showBandMap, setShowBandMap] = useState(() => localStorage.getItem('bandmap.show') === '1');
|
const [showBandMap, setShowBandMap] = useState(() => localStorage.getItem('bandmap.show') === '1');
|
||||||
@@ -858,12 +905,12 @@ export default function App() {
|
|||||||
// map ("map1"), the locator street map ("map2"), the cluster grid or the
|
// map ("map1"), the locator street map ("map2"), the cluster grid or the
|
||||||
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
|
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
|
||||||
// so it's loaded async on mount and re-read on profile:changed below.
|
// so it's loaded async on mount and re-read on profile:changed below.
|
||||||
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent';
|
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'netcontrol';
|
||||||
const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
|
const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
|
||||||
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
|
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
|
||||||
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
|
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
|
||||||
const loadMainPanes = useCallback(async () => {
|
const loadMainPanes = useCallback(async () => {
|
||||||
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent';
|
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'netcontrol';
|
||||||
const [l, r] = await Promise.all([
|
const [l, r] = await Promise.all([
|
||||||
GetUIPref('mainPaneLeft').catch(() => ''),
|
GetUIPref('mainPaneLeft').catch(() => ''),
|
||||||
GetUIPref('mainPaneRight').catch(() => ''),
|
GetUIPref('mainPaneRight').catch(() => ''),
|
||||||
@@ -963,11 +1010,15 @@ export default function App() {
|
|||||||
// list once, then compute each shown QSO's reference per award and attach it
|
// list once, then compute each shown QSO's reference per award and attach it
|
||||||
// to the rows (the grids render one hideable column per award).
|
// to the rows (the grids render one hideable column per award).
|
||||||
const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
|
const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
|
||||||
|
// Bumped whenever award definitions are saved so the grid columns AND the
|
||||||
|
// per-QSO refs re-fetch — the ref/name display choice is computed live, so
|
||||||
|
// changing it updates ALL contacts (old included) with no restart.
|
||||||
|
const [awardsVersion, setAwardsVersion] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
GetAwardDefs().then((defs: any[]) =>
|
GetAwardDefs().then((defs: any[]) =>
|
||||||
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
|
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
}, []);
|
}, [awardsVersion]);
|
||||||
const [qsoAwardRefs, setQsoAwardRefs] = useState<Record<string, Record<string, string>>>({});
|
const [qsoAwardRefs, setQsoAwardRefs] = useState<Record<string, Record<string, string>>>({});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ids = (qsos as any[]).map((q) => q.id).filter(Boolean);
|
const ids = (qsos as any[]).map((q) => q.id).filter(Boolean);
|
||||||
@@ -975,7 +1026,7 @@ export default function App() {
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {});
|
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {});
|
||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
}, [qsos, awardCols.length]);
|
}, [qsos, awardCols.length, awardsVersion]);
|
||||||
const qsosWithAwards = useMemo(
|
const qsosWithAwards = useMemo(
|
||||||
() => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })),
|
() => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })),
|
||||||
[qsos, qsoAwardRefs],
|
[qsos, qsoAwardRefs],
|
||||||
@@ -987,7 +1038,7 @@ export default function App() {
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setWbAwardRefs(m ?? {}); }).catch(() => {});
|
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setWbAwardRefs(m ?? {}); }).catch(() => {});
|
||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
}, [wb, awardCols.length]);
|
}, [wb, awardCols.length, awardsVersion]);
|
||||||
const wbWithAwards = useMemo(
|
const wbWithAwards = useMemo(
|
||||||
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null),
|
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null),
|
||||||
[wb, wbAwardRefs],
|
[wb, wbAwardRefs],
|
||||||
@@ -1166,9 +1217,17 @@ export default function App() {
|
|||||||
if (p?.visual) {
|
if (p?.visual) {
|
||||||
const call = String(p?.call ?? ''); const band = String(p?.band ?? ''); const rule = String(p?.rule ?? 'alert');
|
const call = String(p?.call ?? ''); const band = String(p?.band ?? ''); const rule = String(p?.rule ?? 'alert');
|
||||||
showToast(`🔔 ${rule}: ${call}${band ? ` on ${band}` : ''}${p?.country ? ` — ${p.country}` : ''}`);
|
showToast(`🔔 ${rule}: ${call}${band ? ` on ${band}` : ''}${p?.country ? ` — ${p.country}` : ''}`);
|
||||||
|
// Also keep it in the persistent tray (clickable, ~10 min).
|
||||||
|
const a: RecentAlert = {
|
||||||
|
id: Date.now() + Math.random(), rule, call, band,
|
||||||
|
mode: String(p?.mode ?? ''), freq_hz: Number(p?.freq_hz ?? 0),
|
||||||
|
country: String(p?.country ?? ''), comment: String(p?.comment ?? ''), at: Date.now(),
|
||||||
|
};
|
||||||
|
setRecentAlerts((prev) => [a, ...prev.filter((x) => !(x.call === a.call && x.band === a.band))].slice(0, 3));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => { off(); };
|
return () => { off(); };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [showToast]);
|
}, [showToast]);
|
||||||
|
|
||||||
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
||||||
@@ -1641,6 +1700,7 @@ export default function App() {
|
|||||||
setWkMacros((s.macros ?? []) as WKMacro[]);
|
setWkMacros((s.macros ?? []) as WKMacro[]);
|
||||||
setWkEscClears(s.esc_clears_call !== false);
|
setWkEscClears(s.esc_clears_call !== false);
|
||||||
setWkSendOnType(!!s.send_on_type);
|
setWkSendOnType(!!s.send_on_type);
|
||||||
|
setWkEngine(s.engine === 'icom' ? 'icom' : 'winkeyer');
|
||||||
} catch { /* keyer not configured */ }
|
} catch { /* keyer not configured */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1719,6 +1779,15 @@ export default function App() {
|
|||||||
setWkSent('');
|
setWkSent('');
|
||||||
const resolved = resolveCW(rawText);
|
const resolved = resolveCW(rawText);
|
||||||
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
|
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
|
||||||
|
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
|
||||||
|
if (cwSourceRef.current === 'icom') {
|
||||||
|
// The rig's keyer gives no busy echo back, so show the text we sent and,
|
||||||
|
// for <LOGQSO>, wait the estimated send duration before logging.
|
||||||
|
setWkSent(resolved);
|
||||||
|
await IcomSendCW(resolved).catch((e) => setError(String(e?.message ?? e)));
|
||||||
|
if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
await WinkeyerSend(resolved).catch((e) => setError(String(e?.message ?? e)));
|
await WinkeyerSend(resolved).catch((e) => setError(String(e?.message ?? e)));
|
||||||
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
|
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
|
||||||
// finished sending — so the QSO isn't logged (and the form cleared) while CW
|
// finished sending — so the QSO isn't logged (and the form cleared) while CW
|
||||||
@@ -1728,7 +1797,6 @@ export default function App() {
|
|||||||
// send duration (text length × WPM) plus slack: log when busy clears OR the
|
// send duration (text length × WPM) plus slack: log when busy clears OR the
|
||||||
// estimate elapses, whichever comes first.
|
// estimate elapses, whichever comes first.
|
||||||
if (!doLog) return;
|
if (!doLog) return;
|
||||||
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
|
|
||||||
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag
|
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag
|
||||||
for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start
|
for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start
|
||||||
const deadline = Date.now() + capMs;
|
const deadline = Date.now() + capMs;
|
||||||
@@ -1747,14 +1815,19 @@ export default function App() {
|
|||||||
const m = wkMacros[i];
|
const m = wkMacros[i];
|
||||||
if (!m) break;
|
if (!m) break;
|
||||||
await wkSend(m.text);
|
await wkSend(m.text);
|
||||||
// Wait for the message to finish before the gap+resend. Cap the wait at the
|
// Wait for the message to finish before the gap+resend. The Icom keyer has
|
||||||
// ESTIMATED send time (not the busy flag alone): over a remote/serial-over-IP
|
// no busy echo, so just wait the estimated send time. For the WinKeyer, cap
|
||||||
// link the keyer's "busy" status lags badly and stays stuck true for tens of
|
// the wait at the ESTIMATED send time (not the busy flag alone): over a
|
||||||
// seconds, which made the next CQ fire ~120s late — i.e. auto-call looked dead.
|
// remote/serial-over-IP link the "busy" status lags badly and stays stuck
|
||||||
|
// true for tens of seconds, which made the next CQ fire ~120s late.
|
||||||
|
if (cwSourceRef.current === 'icom') {
|
||||||
|
await sleep(Math.round(estimateCwMs(resolveCW(m.text), wkWpm)) + 300);
|
||||||
|
} else {
|
||||||
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500;
|
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500;
|
||||||
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start
|
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start
|
||||||
const deadline = Date.now() + capMs;
|
const deadline = Date.now() + capMs;
|
||||||
while (wkBusyRef.current && gen === autoCallGenRef.current && Date.now() < deadline) await sleep(50);
|
while (wkBusyRef.current && gen === autoCallGenRef.current && Date.now() < deadline) await sleep(50);
|
||||||
|
}
|
||||||
if (gen !== autoCallGenRef.current) break;
|
if (gen !== autoCallGenRef.current) break;
|
||||||
await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
|
await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
|
||||||
}
|
}
|
||||||
@@ -2325,6 +2398,7 @@ export default function App() {
|
|||||||
{ type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' },
|
{ type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
|
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
|
||||||
|
{ type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' },
|
||||||
{ type: 'item', label: t('tools.alerts'), action: 'tools.alerts' },
|
{ type: 'item', label: t('tools.alerts'), action: 'tools.alerts' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: t('tools.duplicates'), action: 'tools.duplicates', disabled: total === 0 },
|
{ type: 'item', label: t('tools.duplicates'), action: 'tools.duplicates', disabled: total === 0 },
|
||||||
@@ -2337,7 +2411,7 @@ export default function App() {
|
|||||||
{ name: 'help', label: t('menu.help'), items: [
|
{ name: 'help', label: t('menu.help'), items: [
|
||||||
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
||||||
]},
|
]},
|
||||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, t]);
|
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, t]);
|
||||||
|
|
||||||
function handleMenu(action: string) {
|
function handleMenu(action: string) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -2357,6 +2431,7 @@ export default function App() {
|
|||||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||||
case 'tools.cwdecoder': toggleCwDecoder(); break;
|
case 'tools.cwdecoder': toggleCwDecoder(); break;
|
||||||
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
|
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
|
||||||
|
case 'tools.contest': setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break;
|
||||||
case 'tools.alerts': setAlertsOpen(true); break;
|
case 'tools.alerts': setAlertsOpen(true); break;
|
||||||
case 'tools.duplicates': setShowDuplicates(true); break;
|
case 'tools.duplicates': setShowDuplicates(true); break;
|
||||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||||
@@ -2470,19 +2545,42 @@ export default function App() {
|
|||||||
<div className="flex flex-col w-44">
|
<div className="flex flex-col w-44">
|
||||||
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
|
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
|
||||||
{t('field.callsign')}
|
{t('field.callsign')}
|
||||||
{lookupBusy && <Badge variant="secondary" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider"><Loader2 className="size-2.5 mr-1 animate-spin" />Looking up…</Badge>}
|
{lookupBusy && (
|
||||||
{!lookupBusy && lookupResult && (
|
<span className="inline-flex items-center gap-1 rounded-full bg-muted/70 px-1.5 py-[1px] text-[9px] font-medium text-muted-foreground ring-1 ring-inset ring-border/60">
|
||||||
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider" variant="outline">
|
<Loader2 className="size-2.5 animate-spin" />…
|
||||||
<CheckCircle2 className="size-2.5 mr-1" />{lookupResult.source}
|
</span>
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
|
{!lookupBusy && lookupResult && (() => {
|
||||||
|
const src = (lookupResult.source || '').toLowerCase();
|
||||||
|
const tone = src.includes('cache') ? 'amber' : src.includes('qrz') ? 'emerald' : src.includes('ham') ? 'sky' : 'slate';
|
||||||
|
const ring = { amber: 'bg-warning/12 text-warning ring-warning/30', emerald: 'bg-success/12 text-success ring-success/30', sky: 'bg-info/12 text-info ring-info/30', slate: 'bg-muted-foreground/12 text-muted-foreground ring-muted-foreground/30' }[tone];
|
||||||
|
const dot = { amber: 'bg-warning', emerald: 'bg-success', sky: 'bg-info', slate: 'bg-muted-foreground' }[tone];
|
||||||
|
return (
|
||||||
|
<span className={cn('inline-flex items-center gap-1 rounded-full px-1.5 py-[1px] text-[9px] font-semibold uppercase tracking-wide ring-1 ring-inset', ring)}>
|
||||||
|
<span className={cn('size-1.5 rounded-full', dot)} />{lookupResult.source}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{!lookupBusy && !lookupResult && lookupError && (
|
{!lookupBusy && !lookupResult && lookupError && (
|
||||||
<Badge variant="destructive" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider">{lookupError}</Badge>
|
<span className="inline-flex items-center rounded-full bg-danger/12 px-1.5 py-[1px] text-[9px] font-semibold text-danger ring-1 ring-inset ring-danger/30">{lookupError}</span>
|
||||||
|
)}
|
||||||
|
{callsign.trim() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t('qrz.openTitle', { call: callsign.trim().toUpperCase() })}
|
||||||
|
onClick={() => {
|
||||||
|
const c = callsign.trim().toUpperCase().split('/').map(encodeURIComponent).join('/');
|
||||||
|
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch((e) => setError(String(e?.message ?? e)));
|
||||||
|
}}
|
||||||
|
className="ml-auto shrink-0 inline-flex items-center gap-0.5 text-[9px] font-semibold normal-case tracking-wider text-primary hover:underline"
|
||||||
|
>
|
||||||
|
QRZ ↗
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{contest.active && contestDupe && (
|
{contest.active && contestDupe && (
|
||||||
<span className="absolute -top-2 right-1 z-20 rounded bg-red-600 px-1.5 py-0.5 text-[10px] font-black tracking-widest text-white shadow animate-pulse pointer-events-none">
|
<span className="absolute -top-2 right-1 z-20 rounded bg-destructive px-1.5 py-0.5 text-[10px] font-black tracking-widest text-destructive-foreground shadow animate-pulse pointer-events-none">
|
||||||
DUPE
|
DUPE
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -2492,16 +2590,16 @@ export default function App() {
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={resetRecordingClock}
|
onClick={resetRecordingClock}
|
||||||
title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange"
|
title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange"
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-red-600 whitespace-nowrap cursor-pointer rounded px-1 hover:bg-red-50"
|
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-destructive whitespace-nowrap cursor-pointer rounded px-1 hover:bg-destructive-muted"
|
||||||
>
|
>
|
||||||
<span className="size-2 rounded-full bg-red-600 animate-pulse" />
|
<span className="size-2 rounded-full bg-destructive animate-pulse" />
|
||||||
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
|
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<Input
|
<Input
|
||||||
ref={callsignRef}
|
ref={callsignRef}
|
||||||
className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card',
|
className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card',
|
||||||
contest.active && contestDupe && 'ring-2 ring-red-500 !bg-red-50 focus:!bg-red-50 text-red-700')}
|
contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground')}
|
||||||
value={callsign}
|
value={callsign}
|
||||||
onChange={(e) => onCallsignInput(e.target.value)}
|
onChange={(e) => onCallsignInput(e.target.value)}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
@@ -2538,7 +2636,7 @@ export default function App() {
|
|||||||
// a past QSO). Sets the DATE part of qsoStartedAt; the time field keeps the time.
|
// a past QSO). Sets the DATE part of qsoStartedAt; the time field keeps the time.
|
||||||
const dateBlock = locks.start ? (
|
const dateBlock = locks.start ? (
|
||||||
<div className="flex flex-col w-40">
|
<div className="flex flex-col w-40">
|
||||||
<Label className="mb-1 h-3.5 text-emerald-700">Date</Label>
|
<Label className="mb-1 h-3.5 text-success">Date</Label>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
value={qsoStartedAt ? qsoStartedAt.toISOString().slice(0, 10) : ''}
|
value={qsoStartedAt ? qsoStartedAt.toISOString().slice(0, 10) : ''}
|
||||||
@@ -2556,7 +2654,7 @@ export default function App() {
|
|||||||
) : null;
|
) : null;
|
||||||
const startBlock = (
|
const startBlock = (
|
||||||
<div className="flex flex-col w-28">
|
<div className="flex flex-col w-28">
|
||||||
<Label className="mb-1 h-3.5 flex items-center gap-1 text-emerald-700">{t('field.startUtc')} <LockBtn k="start" title="start time" /></Label>
|
<Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockBtn k="start" title="start time" /></Label>
|
||||||
<Input
|
<Input
|
||||||
readOnly={!locks.start}
|
readOnly={!locks.start}
|
||||||
tabIndex={locks.start ? 0 : -1}
|
tabIndex={locks.start ? 0 : -1}
|
||||||
@@ -2575,7 +2673,7 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const endBlock = (
|
const endBlock = (
|
||||||
<div className="flex flex-col w-28">
|
<div className="flex flex-col w-28">
|
||||||
<Label className="mb-1 h-3.5 flex items-center gap-1 text-rose-700">{t('field.endUtc')} <LockBtn k="end" title="end time" /></Label>
|
<Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')} <LockBtn k="end" title="end time" /></Label>
|
||||||
<Input
|
<Input
|
||||||
readOnly={!locks.end}
|
readOnly={!locks.end}
|
||||||
tabIndex={locks.end ? 0 : -1}
|
tabIndex={locks.end ? 0 : -1}
|
||||||
@@ -2612,7 +2710,7 @@ export default function App() {
|
|||||||
<div className="flex flex-col w-16 shrink-0"><Label className="mb-1 h-3.5">{t('field.snt')}</Label>
|
<div className="flex flex-col w-16 shrink-0"><Label className="mb-1 h-3.5">{t('field.snt')}</Label>
|
||||||
<Input readOnly tabIndex={-1} value={contestSnt} className="font-mono bg-muted/40" />
|
<Input readOnly tabIndex={-1} value={contestSnt} className="font-mono bg-muted/40" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-24 shrink-0"><Label className="mb-1 h-3.5 text-amber-600 font-bold">{t('field.rcv')}</Label>
|
<div className="flex flex-col w-24 shrink-0"><Label className="mb-1 h-3.5 text-warning font-bold">{t('field.rcv')}</Label>
|
||||||
<Input value={rcv} placeholder="exch" className="font-mono"
|
<Input value={rcv} placeholder="exch" className="font-mono"
|
||||||
onChange={(e) => setRcv(e.target.value.toUpperCase())} />
|
onChange={(e) => setRcv(e.target.value.toUpperCase())} />
|
||||||
</div>
|
</div>
|
||||||
@@ -2627,6 +2725,49 @@ export default function App() {
|
|||||||
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
|
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
// A discreet spot-alert LED (bell) that fills the gap at the right of the Grid
|
||||||
|
// row instead of the intrusive floating cards. It lights + shows a count when
|
||||||
|
// alerts are pending; click it to see the last 3 (each clickable to tune).
|
||||||
|
const hasAlerts = recentAlerts.length > 0;
|
||||||
|
// Only show the bell when there are pending alerts — hidden otherwise.
|
||||||
|
const alertLedBlock = !hasAlerts ? null : (
|
||||||
|
<div className="relative self-end mb-0.5 shrink-0">
|
||||||
|
<button type="button" onClick={() => setAlertsPanelOpen((o) => !o)}
|
||||||
|
title={hasAlerts ? t('alert.pending', { n: recentAlerts.length }) : t('alert.noneShort')}
|
||||||
|
className={cn('relative inline-flex size-8 items-center justify-center rounded-full border transition-colors',
|
||||||
|
hasAlerts ? 'border-warning bg-warning/15 text-warning' : 'border-border bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
<Bell className="size-4" />
|
||||||
|
{hasAlerts && <span className="absolute -right-1 -top-1 inline-flex size-2.5 rounded-full bg-warning animate-ping" />}
|
||||||
|
{hasAlerts && <span className="absolute -right-1 -top-1 inline-flex size-2.5 rounded-full bg-warning" />}
|
||||||
|
</button>
|
||||||
|
{alertsPanelOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setAlertsPanelOpen(false)} />
|
||||||
|
<div className="absolute right-0 top-9 z-50 w-72 rounded-lg border border-border bg-card shadow-xl overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border/60 bg-muted/30">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{t('alert.recent')}</span>
|
||||||
|
{hasAlerts && <button type="button" className="text-[11px] text-muted-foreground hover:text-foreground" onClick={() => setRecentAlerts([])}>{t('alert.clear')}</button>}
|
||||||
|
</div>
|
||||||
|
{!hasAlerts ? (
|
||||||
|
<div className="px-3 py-4 text-center text-xs text-muted-foreground">{t('alert.noneShort')}</div>
|
||||||
|
) : recentAlerts.map((a) => (
|
||||||
|
<button key={a.id} type="button" title={t('alert.tuneHint')}
|
||||||
|
onClick={() => { handleSpotClick({ comment: a.comment, freq_hz: a.freq_hz, band: a.band, dx_call: a.call }); setAlertsPanelOpen(false); }}
|
||||||
|
className="block w-full text-left px-3 py-2 border-b border-border/30 last:border-0 hover:bg-warning/10 transition-colors">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-bold text-sm truncate">{a.call}</span>
|
||||||
|
{a.freq_hz > 0 && <span className="ml-auto shrink-0 text-[11px] font-mono tabular-nums text-warning">{(a.freq_hz / 1e6).toFixed(3)}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground truncate">
|
||||||
|
{a.rule}{a.band ? ` · ${a.band}` : ''}{a.mode ? ` · ${a.mode}` : ''}{a.country ? ` · ${a.country}` : ''}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
// Compact-strip Country (stacked label) + a narrow Comment.
|
// Compact-strip Country (stacked label) + a narrow Comment.
|
||||||
const countryBlockSm = (
|
const countryBlockSm = (
|
||||||
<div className="flex flex-col w-36">
|
<div className="flex flex-col w-36">
|
||||||
@@ -2694,7 +2835,7 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const rxFreqBlock = (
|
const rxFreqBlock = (
|
||||||
<div className="flex flex-col w-32">
|
<div className="flex flex-col w-32">
|
||||||
<Label className={cn('mb-1 h-3.5', catState.split && 'text-rose-600')}>{t('field.rxFreq')}</Label>
|
<Label className={cn('mb-1 h-3.5', catState.split && 'text-danger')}>{t('field.rxFreq')}</Label>
|
||||||
<Input
|
<Input
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
|
value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
|
||||||
@@ -2702,7 +2843,7 @@ export default function App() {
|
|||||||
onFocus={() => setFreqFocused(true)}
|
onFocus={() => setFreqFocused(true)}
|
||||||
onBlur={() => setFreqFocused(false)}
|
onBlur={() => setFreqFocused(false)}
|
||||||
onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }}
|
onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }}
|
||||||
className={cn('font-mono', catState.split && 'bg-rose-50/40 border-rose-200 focus:bg-card')}
|
className={cn('font-mono', catState.split && 'bg-danger-muted/40 border-danger-border focus:bg-card')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2854,7 +2995,7 @@ export default function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setClusterLockBand((v) => !v)}
|
onClick={() => setClusterLockBand((v) => !v)}
|
||||||
className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border',
|
className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border',
|
||||||
clusterLockBand ? 'bg-amber-100 text-amber-800 border-amber-300' : 'text-muted-foreground border-border hover:bg-muted')}
|
clusterLockBand ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' : 'text-muted-foreground border-border hover:bg-muted')}
|
||||||
title="Lock to the entry strip's current band"
|
title="Lock to the entry strip's current band"
|
||||||
>
|
>
|
||||||
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {band}
|
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {band}
|
||||||
@@ -2887,7 +3028,7 @@ export default function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setClusterLockMode((v) => !v)}
|
onClick={() => setClusterLockMode((v) => !v)}
|
||||||
className={cn('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px]',
|
className={cn('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px]',
|
||||||
clusterLockMode ? 'bg-amber-100 text-amber-800 border-amber-300' : 'text-muted-foreground border-border hover:bg-muted')}
|
clusterLockMode ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' : 'text-muted-foreground border-border hover:bg-muted')}
|
||||||
title="Only show spots whose mode matches the entry strip"
|
title="Only show spots whose mode matches the entry strip"
|
||||||
>
|
>
|
||||||
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} Lock mode ({mode})
|
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} Lock mode ({mode})
|
||||||
@@ -2898,10 +3039,10 @@ export default function App() {
|
|||||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{([
|
{([
|
||||||
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
|
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
|
||||||
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
|
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' },
|
||||||
{ k: 'new-mode' as SpotStatusKey, label: 'NEW MODE', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
{ k: 'new-mode' as SpotStatusKey, label: 'NEW MODE', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||||
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||||
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
|
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
|
||||||
]).map((s) => {
|
]).map((s) => {
|
||||||
const on = clusterStatusFilter.has(s.k);
|
const on = clusterStatusFilter.has(s.k);
|
||||||
@@ -2921,9 +3062,9 @@ export default function App() {
|
|||||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Mode</div>
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Mode</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{([
|
{([
|
||||||
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-sky-100 text-sky-800 border-sky-300' },
|
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
|
||||||
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' },
|
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
|
||||||
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' },
|
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
||||||
]).map((s) => {
|
]).map((s) => {
|
||||||
const on = clusterModeFilter.has(s.k);
|
const on = clusterModeFilter.has(s.k);
|
||||||
return (
|
return (
|
||||||
@@ -3010,6 +3151,18 @@ export default function App() {
|
|||||||
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
case 'icom':
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
|
||||||
|
<IcomPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'netcontrol':
|
||||||
|
return (
|
||||||
|
<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} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
case 'recent':
|
case 'recent':
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||||
@@ -3051,7 +3204,7 @@ export default function App() {
|
|||||||
<span className="text-sm font-semibold text-primary">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
|
<span className="text-sm font-semibold text-primary">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
|
||||||
<span className="text-[9px] text-muted-foreground">MHz</span>
|
<span className="text-[9px] text-muted-foreground">MHz</span>
|
||||||
<Badge variant="accent" className="font-mono ml-2 text-[9px] py-0">{band}</Badge>
|
<Badge variant="accent" className="font-mono ml-2 text-[9px] py-0">{band}</Badge>
|
||||||
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 font-mono text-[9px] py-0" variant="outline">{mode}</Badge>
|
<Badge className="bg-success-muted text-success-muted-foreground hover:bg-success-muted font-mono text-[9px] py-0" variant="outline">{mode}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-2 font-mono text-[10px] text-muted-foreground">{utcNow}<span className="text-[9px]">Z</span></span>
|
<span className="ml-2 font-mono text-[10px] text-muted-foreground">{utcNow}<span className="text-[9px]">Z</span></span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
@@ -3066,8 +3219,10 @@ export default function App() {
|
|||||||
<header className="grid grid-cols-[auto_auto_1fr_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm">
|
<header className="grid grid-cols-[auto_auto_1fr_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm">
|
||||||
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
|
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
|
||||||
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
|
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
|
||||||
<span className="font-bold text-[15px] tracking-tight">OpsLog</span>
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span className="text-[11px] text-muted-foreground cursor-pointer hover:text-foreground" onClick={() => setShowAbout(true)} title="About OpsLog">v{APP_VERSION}</span>
|
<span className="font-bold text-[15px] tracking-tight leading-none">OpsLog</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground leading-none cursor-pointer hover:text-foreground" onClick={() => setShowAbout(true)} title="About OpsLog">v{APP_VERSION}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Menubar menus={menus} onAction={handleMenu} />
|
<Menubar menus={menus} onAction={handleMenu} />
|
||||||
@@ -3084,10 +3239,10 @@ export default function App() {
|
|||||||
<button className="shrink-0 hover:text-destructive/70" onClick={() => setError('')}><X className="size-3" /></button>
|
<button className="shrink-0 hover:text-destructive/70" onClick={() => setError('')}><X className="size-3" /></button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1.5 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
|
<div className="flex items-center gap-1.5 rounded-md border border-success-border bg-success-muted text-success-muted-foreground px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
|
||||||
<Satellite className="size-3.5 shrink-0" />
|
<Satellite className="size-3.5 shrink-0" />
|
||||||
<span className="truncate" title={toast}>{toast}</span>
|
<span className="truncate" title={toast}>{toast}</span>
|
||||||
<button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={dismissToast}><X className="size-3" /></button>
|
<button className="shrink-0 text-success hover:text-success" onClick={dismissToast}><X className="size-3" /></button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -3096,14 +3251,14 @@ export default function App() {
|
|||||||
<span className="text-2xl font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
|
<span className="text-2xl font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
|
||||||
{catState.split && rxFreqMhz && (
|
{catState.split && rxFreqMhz && (
|
||||||
<span className="text-[10px] text-muted-foreground mt-0.5">
|
<span className="text-[10px] text-muted-foreground mt-0.5">
|
||||||
<span className="text-rose-600 font-semibold mr-1">RX</span>
|
<span className="text-danger font-semibold mr-1">RX</span>
|
||||||
{fmtFreqDots(rxFreqMhz)}
|
{fmtFreqDots(rxFreqMhz)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-muted-foreground uppercase">MHz</span>
|
<span className="text-[10px] text-muted-foreground uppercase">MHz</span>
|
||||||
{catState.split && (
|
{catState.split && (
|
||||||
<Badge className="bg-amber-100 text-amber-800 border-amber-300 font-mono text-[10px] py-0" variant="outline">SPLIT</Badge>
|
<Badge className="bg-warning-muted text-warning-muted-foreground border-warning-border font-mono text-[10px] py-0" variant="outline">SPLIT</Badge>
|
||||||
)}
|
)}
|
||||||
{/* Band & mode removed here — shown in the QSO entry strip below. */}
|
{/* Band & mode removed here — shown in the QSO entry strip below. */}
|
||||||
{catState.enabled && catState.backend === 'omnirig' && (
|
{catState.enabled && catState.backend === 'omnirig' && (
|
||||||
@@ -3127,7 +3282,7 @@ export default function App() {
|
|||||||
const disabled = !p;
|
const disabled = !p;
|
||||||
const goto = (az: number) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)));
|
const goto = (az: number) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)));
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex items-center rounded-full border border-sky-300 bg-sky-100 overflow-hidden text-[11px] font-mono font-semibold">
|
<div className="inline-flex items-center rounded-full border border-info-border bg-info-muted overflow-hidden text-[11px] font-mono font-semibold">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -3139,7 +3294,7 @@ export default function App() {
|
|||||||
'inline-flex items-center gap-1 px-2 py-0.5 transition-colors',
|
'inline-flex items-center gap-1 px-2 py-0.5 transition-colors',
|
||||||
disabled
|
disabled
|
||||||
? 'text-muted-foreground/60 cursor-not-allowed'
|
? 'text-muted-foreground/60 cursor-not-allowed'
|
||||||
: 'text-sky-800 hover:bg-sky-200 cursor-pointer',
|
: 'text-info-muted-foreground hover:bg-info-muted cursor-pointer',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Compass className="size-3" />
|
<Compass className="size-3" />
|
||||||
@@ -3151,10 +3306,10 @@ export default function App() {
|
|||||||
onClick={() => p && goto(p.bearingLong)}
|
onClick={() => p && goto(p.bearingLong)}
|
||||||
title={p ? `Rotate long-path · ${Math.round(p.distanceLong).toLocaleString()} km` : ''}
|
title={p ? `Rotate long-path · ${Math.round(p.distanceLong).toLocaleString()} km` : ''}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-1.5 py-0.5 border-l border-sky-300 text-[10px] transition-colors',
|
'px-1.5 py-0.5 border-l border-info-border text-[10px] transition-colors',
|
||||||
disabled
|
disabled
|
||||||
? 'text-muted-foreground/60 cursor-not-allowed'
|
? 'text-muted-foreground/60 cursor-not-allowed'
|
||||||
: 'text-sky-800 hover:bg-sky-200 cursor-pointer',
|
: 'text-info-muted-foreground hover:bg-info-muted cursor-pointer',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
LP {p ? `${Math.round(p.bearingLong)}°` : '—'}
|
LP {p ? `${Math.round(p.bearingLong)}°` : '—'}
|
||||||
@@ -3163,7 +3318,7 @@ export default function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => RotatorStop().catch((err) => setError(String(err?.message ?? err)))}
|
onClick={() => RotatorStop().catch((err) => setError(String(err?.message ?? err)))}
|
||||||
title="Stop rotation"
|
title="Stop rotation"
|
||||||
className="px-1.5 py-0.5 border-l border-sky-300 text-rose-700 hover:bg-rose-100 hover:text-rose-800 cursor-pointer transition-colors"
|
className="px-1.5 py-0.5 border-l border-info-border text-danger hover:bg-danger-muted hover:text-danger-muted-foreground cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<Square className="size-2.5 fill-current" />
|
<Square className="size-2.5 fill-current" />
|
||||||
</button>
|
</button>
|
||||||
@@ -3173,16 +3328,16 @@ export default function App() {
|
|||||||
|
|
||||||
{/* Ultrabeam pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */}
|
{/* Ultrabeam pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */}
|
||||||
{ubStatus.enabled && (
|
{ubStatus.enabled && (
|
||||||
<div className="inline-flex items-center rounded-full border border-emerald-300 bg-emerald-50 overflow-hidden text-[10px] font-semibold ml-1"
|
<div className="inline-flex items-center rounded-full border border-success-border bg-success-muted overflow-hidden text-[10px] font-semibold ml-1"
|
||||||
title={ubStatus.connected ? (ubStatus.moving ? 'Ultrabeam: moving…' : 'Ultrabeam pattern') : 'Ultrabeam: connecting…'}>
|
title={ubStatus.connected ? (ubStatus.moving ? 'Ultrabeam: moving…' : 'Ultrabeam pattern') : 'Ultrabeam: connecting…'}>
|
||||||
<button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings">
|
<button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings">
|
||||||
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-amber-500' : 'bg-emerald-500') : 'bg-muted-foreground/40')} />
|
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-warning' : 'bg-success') : 'bg-muted-foreground/40')} />
|
||||||
</button>
|
</button>
|
||||||
{([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: 'Reverse (180°)' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => (
|
{([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: 'Reverse (180°)' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => (
|
||||||
<button key={o.d} type="button" disabled={!ubStatus.connected} title={o.t}
|
<button key={o.d} type="button" disabled={!ubStatus.connected} title={o.t}
|
||||||
onClick={() => { SetUltrabeamDirection(o.d).then(() => setUbStatus((s) => ({ ...s, direction: o.d }))).catch((e: any) => setError(String(e?.message ?? e))); }}
|
onClick={() => { SetUltrabeamDirection(o.d).then(() => setUbStatus((s) => ({ ...s, direction: o.d }))).catch((e: any) => setError(String(e?.message ?? e))); }}
|
||||||
className={cn('px-1.5 py-0.5 transition-colors',
|
className={cn('px-1.5 py-0.5 transition-colors',
|
||||||
ubStatus.direction === o.d ? 'bg-emerald-600 text-white' : 'text-emerald-800 hover:bg-emerald-100',
|
ubStatus.direction === o.d ? 'bg-success text-success-foreground' : 'text-success-muted-foreground hover:bg-success-muted',
|
||||||
!ubStatus.connected && 'opacity-40 cursor-default')}>
|
!ubStatus.connected && 'opacity-40 cursor-default')}>
|
||||||
{o.l}
|
{o.l}
|
||||||
</button>
|
</button>
|
||||||
@@ -3198,13 +3353,13 @@ export default function App() {
|
|||||||
title={dvkStat.playing ? 'Voice keyer — playing' : dvkEnabled ? 'Voice keyer (DVK) — open · click to close' : 'Voice keyer (DVK) · click to open'}
|
title={dvkStat.playing ? 'Voice keyer — playing' : dvkEnabled ? 'Voice keyer (DVK) — open · click to close' : 'Voice keyer (DVK) · click to open'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||||
dvkStat.playing ? 'border-rose-300 bg-rose-100 text-rose-700'
|
dvkStat.playing ? 'border-danger-border bg-danger-muted text-danger-muted-foreground'
|
||||||
: dvkEnabled ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
|
: dvkEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||||
: 'border-border text-muted-foreground hover:bg-muted',
|
: 'border-border text-muted-foreground hover:bg-muted',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Mic className="size-4" />
|
<Mic className="size-4" />
|
||||||
{dvkStat.playing && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-rose-500 animate-pulse" />}
|
{dvkStat.playing && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-danger animate-pulse" />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -3212,13 +3367,13 @@ export default function App() {
|
|||||||
title={wkEnabled && wkStatus.connected ? `CW keyer (WinKeyer) — connected${wkStatus.busy ? ', sending' : ''} · click to close` : wkEnabled ? 'CW keyer (WinKeyer) — enabled · click to close' : 'CW keyer (WinKeyer) · click to open'}
|
title={wkEnabled && wkStatus.connected ? `CW keyer (WinKeyer) — connected${wkStatus.busy ? ', sending' : ''} · click to close` : wkEnabled ? 'CW keyer (WinKeyer) — enabled · click to close' : 'CW keyer (WinKeyer) · click to open'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||||
wkStatus.busy ? 'border-amber-300 bg-amber-100 text-amber-800'
|
wkStatus.busy ? 'border-warning-border bg-warning-muted text-warning-muted-foreground'
|
||||||
: wkEnabled ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
|
: wkEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||||
: 'border-border text-muted-foreground hover:bg-muted',
|
: 'border-border text-muted-foreground hover:bg-muted',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Zap className="size-4" />
|
<Zap className="size-4" />
|
||||||
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-amber-500 animate-pulse" />}
|
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -3230,13 +3385,13 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||||
cwOn && cwStatus.active ? 'border-emerald-400 bg-emerald-100 text-emerald-800'
|
cwOn && cwStatus.active ? 'border-success bg-success-muted text-success-muted-foreground'
|
||||||
: cwEnabled ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
|
: cwEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||||
: 'border-border text-muted-foreground hover:bg-muted',
|
: 'border-border text-muted-foreground hover:bg-muted',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Ear className="size-4" />
|
<Ear className="size-4" />
|
||||||
{cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-emerald-500 animate-pulse" />}
|
{cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success animate-pulse" />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -3244,7 +3399,7 @@ export default function App() {
|
|||||||
title={showRotor ? 'Rotor compass — shown · click to hide' : 'Rotor compass · click to show'}
|
title={showRotor ? 'Rotor compass — shown · click to hide' : 'Rotor compass · click to show'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||||
showRotor ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
|
showRotor ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||||
: 'border-border text-muted-foreground hover:bg-muted',
|
: 'border-border text-muted-foreground hover:bg-muted',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -3257,12 +3412,12 @@ export default function App() {
|
|||||||
title={showAntGenius ? 'Antenna Genius — shown · click to hide' : 'Antenna Genius · click to show'}
|
title={showAntGenius ? 'Antenna Genius — shown · click to hide' : 'Antenna Genius · click to show'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||||
showAntGenius ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
|
showAntGenius ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||||
: 'border-border text-muted-foreground hover:bg-muted',
|
: 'border-border text-muted-foreground hover:bg-muted',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Antenna className="size-4" />
|
<Antenna className="size-4" />
|
||||||
{showAntGenius && agStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-emerald-500" />}
|
{showAntGenius && agStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{chatAvailable && (
|
{chatAvailable && (
|
||||||
@@ -3271,12 +3426,12 @@ export default function App() {
|
|||||||
onClick={() => setChatOpen((o) => !o)}
|
onClick={() => setChatOpen((o) => !o)}
|
||||||
title={`Multi-op chat${chatUnread > 0 ? ` — ${chatUnread} new` : ''}`}
|
title={`Multi-op chat${chatUnread > 0 ? ` — ${chatUnread} new` : ''}`}
|
||||||
className={cn('relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
className={cn('relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||||
chatShown ? 'border-sky-300 bg-sky-50 text-sky-700 hover:bg-sky-100'
|
chatShown ? 'border-info-border bg-info-muted text-info-muted-foreground hover:bg-info-muted'
|
||||||
: 'border-border text-muted-foreground hover:bg-muted')}
|
: 'border-border text-muted-foreground hover:bg-muted')}
|
||||||
>
|
>
|
||||||
<MessageSquare className="size-4" />
|
<MessageSquare className="size-4" />
|
||||||
{chatUnread > 0 && (
|
{chatUnread > 0 && (
|
||||||
<span className="absolute -top-1 -right-1 min-w-3.5 h-3.5 px-0.5 rounded-full bg-rose-500 text-white text-[9px] font-bold leading-[14px] text-center">
|
<span className="absolute -top-1 -right-1 min-w-3.5 h-3.5 px-0.5 rounded-full bg-danger text-danger-foreground text-[9px] font-bold leading-[14px] text-center">
|
||||||
{chatUnread > 9 ? '9+' : chatUnread}
|
{chatUnread > 9 ? '9+' : chatUnread}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -3299,7 +3454,7 @@ export default function App() {
|
|||||||
<Antenna className="size-3" />
|
<Antenna className="size-3" />
|
||||||
{station.callsign}
|
{station.callsign}
|
||||||
{station.my_grid && (
|
{station.my_grid && (
|
||||||
<span className="bg-white/60 px-1.5 rounded text-[11px] text-primary">{station.my_grid}</span>
|
<span className="bg-card/60 px-1.5 rounded text-[11px] text-primary">{station.my_grid}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -3398,7 +3553,7 @@ export default function App() {
|
|||||||
Update available: v{updateInfo.latest} — download
|
Update available: v{updateInfo.latest} — download
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-1 text-[11px] text-emerald-600">You're up to date</p>
|
<p className="mt-1 text-[11px] text-success">You're up to date</p>
|
||||||
)}
|
)}
|
||||||
<p className="mt-3 text-sm">
|
<p className="mt-3 text-sm">
|
||||||
Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span>
|
Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span>
|
||||||
@@ -3497,13 +3652,15 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Name fixed to the Band/Mode/Country column width (300px) so
|
{/* Row 2: Name fixed to the Band/Mode/Country column width (300px) so
|
||||||
its right edge lines up with that column below; QTH grows to fill. */}
|
its right edge lines up with that column below; QTH grows to fill.
|
||||||
<div className="flex gap-2 items-end">
|
gap-4 matches Row 3 so QTH's left edge aligns with Comment/Note. */}
|
||||||
|
<div className="flex gap-4 items-end">
|
||||||
<div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label>
|
<div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label>
|
||||||
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
|
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
|
||||||
</div>
|
</div>
|
||||||
{qthBlock}
|
{qthBlock}
|
||||||
{gridBlock}
|
{gridBlock}
|
||||||
|
{alertLedBlock}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 3: tight left detail column (Band/Mode/Country) and
|
{/* Row 3: tight left detail column (Band/Mode/Country) and
|
||||||
@@ -3614,20 +3771,29 @@ export default function App() {
|
|||||||
{wkEnabled && (
|
{wkEnabled && (
|
||||||
<div className="w-[380px] shrink-0 min-h-0">
|
<div className="w-[380px] shrink-0 min-h-0">
|
||||||
<WinkeyerPanel
|
<WinkeyerPanel
|
||||||
status={wkStatus}
|
status={cwSource === 'icom'
|
||||||
|
? { connected: catState.backend === 'icom' && catState.connected, busy: false, wpm: wkWpm, version: 0, port: 'CI-V' }
|
||||||
|
: wkStatus}
|
||||||
ports={wkPorts}
|
ports={wkPorts}
|
||||||
port={wkPort}
|
port={wkPort}
|
||||||
wpm={wkWpm}
|
wpm={wkWpm}
|
||||||
macros={wkMacros}
|
macros={wkMacros}
|
||||||
sent={wkSent}
|
sent={wkSent}
|
||||||
|
source={cwSource}
|
||||||
|
breakIn={icomBreakIn}
|
||||||
|
onSetBreakIn={setBreakIn}
|
||||||
onSelectPort={wkSelectPort}
|
onSelectPort={wkSelectPort}
|
||||||
onRefreshPorts={reloadWkPorts}
|
onRefreshPorts={reloadWkPorts}
|
||||||
onConnect={() => WinkeyerConnect().catch((e) => setError(String(e?.message ?? e)))}
|
onConnect={() => WinkeyerConnect().catch((e) => setError(String(e?.message ?? e)))}
|
||||||
onDisconnect={() => WinkeyerDisconnect().catch(() => {})}
|
onDisconnect={() => WinkeyerDisconnect().catch(() => {})}
|
||||||
onSetSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
|
onSetSpeed={(w) => {
|
||||||
|
setWkWpm(w); saveWk({ wpm: w });
|
||||||
|
if (cwSource === 'icom') IcomSetKeySpeed(w).catch(() => {});
|
||||||
|
else WinkeyerSetSpeed(w).catch(() => {});
|
||||||
|
}}
|
||||||
onSend={wkSend}
|
onSend={wkSend}
|
||||||
onSendMacro={wkSendMacro}
|
onSendMacro={wkSendMacro}
|
||||||
onStop={() => { stopAutoCall(); WinkeyerStop().catch(() => {}); }}
|
onStop={() => { stopAutoCall(); if (cwSource === 'icom') IcomStopCW().catch(() => {}); else WinkeyerStop().catch(() => {}); }}
|
||||||
onClose={() => wkSetEnabled(false)}
|
onClose={() => wkSetEnabled(false)}
|
||||||
sendOnType={wkSendOnType}
|
sendOnType={wkSendOnType}
|
||||||
onToggleSendOnType={wkToggleSendOnType}
|
onToggleSendOnType={wkToggleSendOnType}
|
||||||
@@ -3667,12 +3833,12 @@ export default function App() {
|
|||||||
|
|
||||||
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
|
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
|
||||||
{cwOn && (
|
{cwOn && (
|
||||||
<div className="ml-2.5 mt-1.5 -mb-1 w-[45%] flex items-center gap-2 rounded-md border border-emerald-300/70 bg-emerald-50/60 px-2 py-1.5 text-xs">
|
<div className="ml-2.5 mt-1.5 -mb-1 w-[45%] flex items-center gap-2 rounded-md border border-success-border/70 bg-success-muted/60 px-2 py-1.5 text-xs">
|
||||||
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-emerald-600' : 'text-muted-foreground')} />
|
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-success' : 'text-muted-foreground')} />
|
||||||
{/* Input-level meter — if this stays flat with a strong signal, the RX
|
{/* Input-level meter — if this stays flat with a strong signal, the RX
|
||||||
audio device is wrong/silent rather than a decode problem. */}
|
audio device is wrong/silent rather than a decode problem. */}
|
||||||
<div className="shrink-0 w-12 h-1.5 rounded bg-muted overflow-hidden" title={`Audio level ${Math.round(cwStatus.level * 100)}%`}>
|
<div className="shrink-0 w-12 h-1.5 rounded bg-muted overflow-hidden" title={`Audio level ${Math.round(cwStatus.level * 100)}%`}>
|
||||||
<div className="h-full bg-emerald-500 transition-[width] duration-100" style={{ width: `${Math.min(100, Math.round(cwStatus.level * 100))}%` }} />
|
<div className="h-full bg-success transition-[width] duration-100" style={{ width: `${Math.min(100, Math.round(cwStatus.level * 100))}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
|
<span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||||
{cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'}
|
{cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'}
|
||||||
@@ -3684,7 +3850,7 @@ export default function App() {
|
|||||||
onChange={(e) => setCwPitch(e.target.value)}
|
onChange={(e) => setCwPitch(e.target.value)}
|
||||||
placeholder="auto"
|
placeholder="auto"
|
||||||
title="Lock the decoder to this pitch (Hz). Blank = follow the radio's CW pitch / auto-search."
|
title="Lock the decoder to this pitch (Hz). Blank = follow the radio's CW pitch / auto-search."
|
||||||
className="shrink-0 w-14 h-5 rounded border border-emerald-300/70 bg-white/60 px-1 text-[10px] font-mono text-center outline-none"
|
className="shrink-0 w-14 h-5 rounded border border-success-border/70 bg-card/60 px-1 text-[10px] font-mono text-center outline-none"
|
||||||
/>
|
/>
|
||||||
{/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
|
{/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
|
||||||
text (see cwScrollRef effect) so the latest stays in view. */}
|
text (see cwScrollRef effect) so the latest stays in view. */}
|
||||||
@@ -3697,7 +3863,7 @@ export default function App() {
|
|||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
type="button"
|
type="button"
|
||||||
className="mr-1 shrink-0 rounded px-1 leading-none hover:bg-emerald-200/70"
|
className="mr-1 shrink-0 rounded px-1 leading-none hover:bg-success-muted/70"
|
||||||
title="Use as callsign"
|
title="Use as callsign"
|
||||||
onClick={() => onCallsignInput(tok, { force: true })}
|
onClick={() => onCallsignInput(tok, { force: true })}
|
||||||
>
|
>
|
||||||
@@ -3733,10 +3899,22 @@ export default function App() {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="awards">{t('tab.awards')}</TabsTrigger>
|
<TabsTrigger value="awards">{t('tab.awards')}</TabsTrigger>
|
||||||
<TabsTrigger value="bandmap">{t('tab.bandmap')}</TabsTrigger>
|
<TabsTrigger value="bandmap">{t('tab.bandmap')}</TabsTrigger>
|
||||||
<TabsTrigger value="contest" className={cn('gap-1.5', contest.active && 'text-amber-600 data-[state=active]:text-amber-600')}>
|
{contestTabEnabled && (
|
||||||
|
<TabsTrigger value="contest" className={cn('gap-1.5', contest.active && 'text-warning data-[state=active]:text-warning')}>
|
||||||
{t('tab.contest')}
|
{t('tab.contest')}
|
||||||
{contest.active && <span className="inline-block size-1.5 rounded-full bg-amber-500 animate-pulse" />}
|
{contest.active && <span className="inline-block size-1.5 rounded-full bg-warning animate-pulse" />}
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
aria-label="Close Contest"
|
||||||
|
title={t('dup.close')}
|
||||||
|
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||||
|
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setContestTabEnabled(false); setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); }}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
{netEnabled && (
|
{netEnabled && (
|
||||||
<TabsTrigger value="net" className="gap-1.5">
|
<TabsTrigger value="net" className="gap-1.5">
|
||||||
{t('tab.net')}
|
{t('tab.net')}
|
||||||
@@ -3754,20 +3932,6 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
|
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
|
||||||
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
|
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
|
||||||
{/* Not a tab — QRZ blocks embedding, so this opens the call's
|
|
||||||
QRZ.com page in the system browser. Styled like a trigger. */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!callsign.trim()}
|
|
||||||
title={callsign.trim() ? `Open ${callsign.trim().toUpperCase()} on QRZ.com` : 'Enter a callsign first'}
|
|
||||||
onClick={() => {
|
|
||||||
const c = callsign.trim().toUpperCase().split('/').map(encodeURIComponent).join('/');
|
|
||||||
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch((e) => setError(String(e?.message ?? e)));
|
|
||||||
}}
|
|
||||||
className="inline-flex items-center justify-center gap-1 whitespace-nowrap px-3 py-1.5 text-xs font-medium text-muted-foreground border-b-2 border-transparent transition-all hover:text-foreground disabled:pointer-events-none disabled:opacity-50 -mb-px"
|
|
||||||
>
|
|
||||||
QRZ.com ↗
|
|
||||||
</button>
|
|
||||||
{qslTabOpen && (
|
{qslTabOpen && (
|
||||||
<TabsTrigger value="qsl" className="gap-1.5">
|
<TabsTrigger value="qsl" className="gap-1.5">
|
||||||
QSL Manager
|
QSL Manager
|
||||||
@@ -3802,20 +3966,20 @@ export default function App() {
|
|||||||
<div className={cn(
|
<div className={cn(
|
||||||
'mx-2.5 mt-2 px-3 py-2 rounded-md text-xs border flex flex-col gap-1.5',
|
'mx-2.5 mt-2 px-3 py-2 rounded-md text-xs border flex flex-col gap-1.5',
|
||||||
importResult.errors && importResult.errors.length > 0
|
importResult.errors && importResult.errors.length > 0
|
||||||
? 'bg-amber-50 border-amber-300 text-amber-800'
|
? 'bg-warning-muted border-warning-border text-warning-muted-foreground'
|
||||||
: 'bg-emerald-50 border-emerald-300 text-emerald-800',
|
: 'bg-success-muted border-success-border text-success-muted-foreground',
|
||||||
)}>
|
)}>
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<strong>Import complete.</strong>
|
<strong>Import complete.</strong>
|
||||||
<Badge variant="outline" className="bg-white/60 font-mono text-emerald-700 border-emerald-300">{importResult.imported} imported</Badge>
|
<Badge variant="outline" className="bg-card/60 font-mono text-success border-success-border">{importResult.imported} imported</Badge>
|
||||||
{importResult.updated > 0 && (
|
{importResult.updated > 0 && (
|
||||||
<Badge variant="outline" className="bg-white/60 font-mono text-violet-700 border-violet-300">{importResult.updated} updated</Badge>
|
<Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{importResult.updated} updated</Badge>
|
||||||
)}
|
)}
|
||||||
{importResult.duplicates > 0 && (
|
{importResult.duplicates > 0 && (
|
||||||
<Badge variant="outline" className="bg-white/60 font-mono text-sky-700 border-sky-300">{importResult.duplicates} duplicates</Badge>
|
<Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{importResult.duplicates} duplicates</Badge>
|
||||||
)}
|
)}
|
||||||
<Badge variant="outline" className="bg-white/60 font-mono text-amber-700 border-amber-300">{importResult.skipped} skipped</Badge>
|
<Badge variant="outline" className="bg-card/60 font-mono text-warning border-warning-border">{importResult.skipped} skipped</Badge>
|
||||||
<Badge variant="outline" className="bg-white/60 font-mono">{importResult.total} total</Badge>
|
<Badge variant="outline" className="bg-card/60 font-mono">{importResult.total} total</Badge>
|
||||||
{importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && (
|
{importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && (
|
||||||
<button className="underline text-xs" onClick={() => setImportDupsOpen((v) => !v)}>
|
<button className="underline text-xs" onClick={() => setImportDupsOpen((v) => !v)}>
|
||||||
{importDupsOpen ? 'Hide' : 'Show'} duplicates
|
{importDupsOpen ? 'Hide' : 'Show'} duplicates
|
||||||
@@ -3891,7 +4055,7 @@ 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 && (
|
{qsos.length >= qsoLimit && qsos.length < total && (
|
||||||
<span className="text-amber-700">Limit reached — raise Max to see more.</span>
|
<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
|
||||||
@@ -3949,14 +4113,14 @@ export default function App() {
|
|||||||
key={s.server_id}
|
key={s.server_id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border',
|
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border',
|
||||||
s.state === 'connected' ? 'bg-emerald-100 text-emerald-800 border-emerald-300' :
|
s.state === 'connected' ? 'bg-success-muted text-success-muted-foreground border-success-border' :
|
||||||
s.state === 'connecting' || s.state === 'reconnecting' ? 'bg-amber-100 text-amber-800 border-amber-300' :
|
s.state === 'connecting' || s.state === 'reconnecting' ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' :
|
||||||
s.state === 'error' ? 'bg-rose-100 text-rose-800 border-rose-300' :
|
s.state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
|
||||||
'bg-muted text-muted-foreground border-border',
|
'bg-muted text-muted-foreground border-border',
|
||||||
)}
|
)}
|
||||||
title={`${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}`}
|
title={`${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}`}
|
||||||
>
|
>
|
||||||
{isMaster && <span className="text-amber-600" title="Master (commands go here)">★</span>}
|
{isMaster && <span className="text-warning" title="Master (commands go here)">★</span>}
|
||||||
{s.name}
|
{s.name}
|
||||||
<span className="opacity-60 text-[9px] ml-0.5">{s.state.toUpperCase()}{s.retries ? ` #${s.retries}` : ''}</span>
|
<span className="opacity-60 text-[9px] ml-0.5">{s.state.toUpperCase()}{s.retries ? ` #${s.retries}` : ''}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -4059,12 +4223,14 @@ export default function App() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
|
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
|
||||||
<AwardsPanel onEditQSO={openEdit} />
|
<AwardsPanel onEditQSO={openEdit} onAwardsChanged={() => setAwardsVersion((v) => v + 1)} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{contestTabEnabled && (
|
||||||
<TabsContent value="contest" className="flex-1 min-h-0 p-0">
|
<TabsContent value="contest" className="flex-1 min-h-0 p-0">
|
||||||
<ContestPanel session={contest} onChange={updateContest} />
|
<ContestPanel session={contest} onChange={updateContest} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* FlexRadio SmartSDR-style control panel — only present when the CAT
|
{/* FlexRadio SmartSDR-style control panel — only present when the CAT
|
||||||
backend is a FlexRadio. */}
|
backend is a FlexRadio. */}
|
||||||
@@ -4094,7 +4260,7 @@ export default function App() {
|
|||||||
|
|
||||||
<TabsContent value="bandmap" className="mt-0 flex flex-col min-h-0 flex-1">
|
<TabsContent value="bandmap" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||||
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-border/60 shrink-0 flex-wrap">
|
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-border/60 shrink-0 flex-wrap">
|
||||||
<span className="text-xs text-muted-foreground mr-1">Bands:</span>
|
<span className="text-xs text-muted-foreground mr-1">{t('bmp.bandsLabel')}</span>
|
||||||
{bands.map((b) => {
|
{bands.map((b) => {
|
||||||
const on = bandMapBands.includes(b);
|
const on = bandMapBands.includes(b);
|
||||||
return (
|
return (
|
||||||
@@ -4105,6 +4271,18 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<span className="ml-auto flex items-center gap-1">
|
||||||
|
<button type="button" onClick={toggleBandMapHideFt} title={t('bmp.hideFtTitle')}
|
||||||
|
className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
|
||||||
|
bandMapHideFt ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t('bmp.hideFt')}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={toggleBandMapFit} title={t('bmp.fitTitle')}
|
||||||
|
className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
|
||||||
|
bandMapFit ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t('bmp.fitBand')}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 flex gap-2 p-2 overflow-x-auto">
|
<div className="flex-1 min-h-0 flex gap-2 p-2 overflow-x-auto">
|
||||||
{bandMapBands.length === 0 ? (
|
{bandMapBands.length === 0 ? (
|
||||||
@@ -4120,6 +4298,8 @@ export default function App() {
|
|||||||
currentFreqHz={band === b && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
|
currentFreqHz={band === b && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
|
||||||
onSpotClick={handleSpotClick}
|
onSpotClick={handleSpotClick}
|
||||||
onClose={() => toggleBandMapBand(b)}
|
onClose={() => toggleBandMapBand(b)}
|
||||||
|
hideDigital={bandMapHideFt}
|
||||||
|
fitToBand={bandMapFit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -4161,7 +4341,7 @@ export default function App() {
|
|||||||
: 'border-border hover:bg-muted cursor-pointer',
|
: 'border-border hover:bg-muted cursor-pointer',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={cn('size-2 rounded-full', on ? 'bg-emerald-500' : 'bg-muted-foreground/40')} />
|
<span className={cn('size-2 rounded-full', on ? 'bg-success' : 'bg-muted-foreground/40')} />
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -4193,7 +4373,7 @@ export default function App() {
|
|||||||
title={dbConn.backend === 'mysql' ? `Shared MySQL logbook — ${dbConn.label}` : `Local SQLite logbook — ${dbConn.label}`}
|
title={dbConn.backend === 'mysql' ? `Shared MySQL logbook — ${dbConn.label}` : `Local SQLite logbook — ${dbConn.label}`}
|
||||||
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground max-w-[340px]"
|
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground max-w-[340px]"
|
||||||
>
|
>
|
||||||
<Database className={cn('size-3 shrink-0', dbConn.backend === 'mysql' ? 'text-emerald-600' : 'text-muted-foreground')} />
|
<Database className={cn('size-3 shrink-0', dbConn.backend === 'mysql' ? 'text-success' : 'text-muted-foreground')} />
|
||||||
<span className="font-mono truncate">{dbConn.label}</span>
|
<span className="font-mono truncate">{dbConn.label}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -4251,6 +4431,7 @@ export default function App() {
|
|||||||
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }}
|
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }}
|
||||||
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
|
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
|
||||||
flexAvailable={catState.backend === 'flex'}
|
flexAvailable={catState.backend === 'flex'}
|
||||||
|
icomAvailable={catState.backend === 'icom'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function AdifExtrasEditor({ value, onChange }: Props) {
|
|||||||
{!def && ''}
|
{!def && ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!def && <span className="block text-[10px] text-amber-600 leading-tight">{t('adx.nonStandard')}</span>}
|
{!def && <span className="block text-[10px] text-warning leading-tight">{t('adx.nonStandard')}</span>}
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
className="flex-1 h-8 text-xs font-mono"
|
className="flex-1 h-8 text-xs font-mono"
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
|
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
|
||||||
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
|
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
|
||||||
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
|
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
|
||||||
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')} />
|
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-success' : 'bg-muted-foreground/40')} />
|
||||||
<span className="truncate flex-1">{r.name}</span>
|
<span className="truncate flex-1">{r.name}</span>
|
||||||
{r.sound && <Volume2 className="size-3 text-muted-foreground shrink-0" />}
|
{r.sound && <Volume2 className="size-3 text-muted-foreground shrink-0" />}
|
||||||
{r.email && <Mail className="size-3 text-muted-foreground shrink-0" />}
|
{r.email && <Mail className="size-3 text-muted-foreground shrink-0" />}
|
||||||
@@ -240,9 +240,9 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
|
|
||||||
{/* Editor actions */}
|
{/* Editor actions */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
|
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
|
||||||
{err && <span className="text-[11px] text-rose-600 flex-1 truncate">{err}</span>}
|
{err && <span className="text-[11px] text-danger flex-1 truncate">{err}</span>}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" className="text-rose-700" onClick={del}><Trash2 className="size-3.5" /> {t('altm.delete')}</Button>
|
<Button variant="ghost" size="sm" className="text-danger" onClick={del}><Trash2 className="size-3.5" /> {t('altm.delete')}</Button>
|
||||||
<Button size="sm" onClick={save}>{t('altm.saveRule')}</Button>
|
<Button size="sm" onClick={save}>{t('altm.saveRule')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -54,12 +54,12 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
|
|||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||||
<Antenna className={cn('size-4', status.connected ? 'text-emerald-600 drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
|
<Antenna className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
|
||||||
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
|
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
|
||||||
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-rose-500')} />
|
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
|
||||||
<span className={status.connected ? 'text-emerald-600' : 'text-rose-500'}>{status.connected ? t('agp.online') : t('agp.offline')}</span>
|
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('agp.online') : t('agp.offline')}</span>
|
||||||
</span>
|
</span>
|
||||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('agp.close')}>
|
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('agp.close')}>
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
@@ -70,7 +70,7 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
|
|||||||
{!status.connected ? (
|
{!status.connected ? (
|
||||||
<div className="text-center py-6 text-xs space-y-2">
|
<div className="text-center py-6 text-xs space-y-2">
|
||||||
<div className="text-muted-foreground italic animate-pulse">{t('agp.connecting')}</div>
|
<div className="text-muted-foreground italic animate-pulse">{t('agp.connecting')}</div>
|
||||||
{status.last_error && <div className="text-rose-500 font-mono text-[10px] break-words px-2">{status.last_error}</div>}
|
{status.last_error && <div className="text-danger font-mono text-[10px] break-words px-2">{status.last_error}</div>}
|
||||||
</div>
|
</div>
|
||||||
) : list.length === 0 ? (
|
) : list.length === 0 ? (
|
||||||
<div className="text-muted-foreground italic text-center py-6 text-xs">{t('agp.noAntennas')}</div>
|
<div className="text-muted-foreground italic text-center py-6 text-xs">{t('agp.noAntennas')}</div>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type RefMeta = { code: string; count: number; updated_at: string; can_update: bo
|
|||||||
export type AwardDef = {
|
export type AwardDef = {
|
||||||
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
||||||
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
||||||
|
ref_display?: string; // grid column shows: ref | name | both
|
||||||
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
||||||
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
|
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
|
||||||
or_rules?: AwardOrRule[];
|
or_rules?: AwardOrRule[];
|
||||||
@@ -264,7 +265,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<button key={i} onClick={() => setSel(i)}
|
<button key={i} onClick={() => setSel(i)}
|
||||||
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
|
||||||
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
|
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
|
||||||
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-emerald-500')} />
|
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
|
||||||
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
<span className="font-mono font-semibold shrink-0">{d.code}</span>
|
||||||
<span className="text-muted-foreground truncate">{d.name}</span>
|
<span className="text-muted-foreground truncate">{d.name}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -301,6 +302,16 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
||||||
<Field2 label={t('awed.awardUrl')}><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
|
<Field2 label={t('awed.awardUrl')}><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
|
||||||
<Field2 label={t('awed.referenceUrl')}><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
|
<Field2 label={t('awed.referenceUrl')}><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
|
||||||
|
<Field2 label={t('awed.refDisplay')}>
|
||||||
|
<Select value={cur.ref_display || 'ref'} onValueChange={(v) => patch({ ref_display: v })}>
|
||||||
|
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ref">{t('awed.refDisplayRef')}</SelectItem>
|
||||||
|
<SelectItem value="name">{t('awed.refDisplayName')}</SelectItem>
|
||||||
|
<SelectItem value="both">{t('awed.refDisplayBoth')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field2>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field2 label={t('awed.validFrom')}><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
|
<Field2 label={t('awed.validFrom')}><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
|
||||||
<Field2 label={t('awed.validTo')}><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
|
<Field2 label={t('awed.validTo')}><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
|
||||||
@@ -567,7 +578,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> {t('awed.searching')}</div>}
|
{busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> {t('awed.searching')}</div>}
|
||||||
{!busy && large && q.trim().length < 2 && (
|
{!busy && large && q.trim().length < 2 && (
|
||||||
<div className="m-2 rounded border border-amber-300 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-800">
|
<div className="m-2 rounded border border-warning-border bg-warning-muted px-2 py-1.5 text-[11px] text-warning-muted-foreground">
|
||||||
{t('awed.tooManyItems', { total: total.toLocaleString() })}
|
{t('awed.tooManyItems', { total: total.toLocaleString() })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange
|
|||||||
<label className="text-xs font-semibold text-muted-foreground">{label}</label>
|
<label className="text-xs font-semibold text-muted-foreground">{label}</label>
|
||||||
<div className="relative" ref={boxRef}>
|
<div className="relative" ref={boxRef}>
|
||||||
{value ? (
|
{value ? (
|
||||||
<div className="flex items-center gap-1.5 h-7 px-2 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 text-xs">
|
<div className="flex items-center gap-1.5 h-7 px-2 rounded-md border border-success-border bg-success-muted text-success-muted-foreground text-xs">
|
||||||
<span className="font-mono font-semibold">{value}</span>
|
<span className="font-mono font-semibold">{value}</span>
|
||||||
<button className="ml-auto hover:text-emerald-950" onClick={() => onChange('')} title={t('awrp.remove')}><X className="size-3.5" /></button>
|
<button className="ml-auto hover:text-success" onClick={() => onChange('')} title={t('awrp.remove')}><X className="size-3.5" /></button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -218,10 +218,10 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
|
|
||||||
{/* Selected ref chip */}
|
{/* Selected ref chip */}
|
||||||
{selectedRef ? (
|
{selectedRef ? (
|
||||||
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-emerald-300 bg-emerald-50 text-emerald-800 text-xs min-w-0">
|
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-success-border bg-success-muted text-success-muted-foreground text-xs min-w-0">
|
||||||
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
|
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
|
||||||
<span className="truncate text-[10px] text-emerald-700">{selectedRef.name}</span>
|
<span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</span>
|
||||||
<button className="ml-auto shrink-0 hover:text-emerald-950" onClick={() => setSelectedRef(null)}>
|
<button className="ml-auto shrink-0 hover:text-success" onClick={() => setSelectedRef(null)}>
|
||||||
<X className="size-3" />
|
<X className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,15 +284,15 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
onClick={() => addRef({ code: autoMatch.code, name: autoMatch.name } as AwardRef)}
|
onClick={() => addRef({ code: autoMatch.code, name: autoMatch.name } as AwardRef)}
|
||||||
className={`text-left rounded border px-1.5 py-1 text-[11px] leading-tight ${
|
className={`text-left rounded border px-1.5 py-1 text-[11px] leading-tight ${
|
||||||
autoAlreadyAdded
|
autoAlreadyAdded
|
||||||
? 'border-emerald-300 bg-emerald-50 text-emerald-800 cursor-default'
|
? 'border-success-border bg-success-muted text-success-muted-foreground cursor-default'
|
||||||
: 'border-emerald-300 bg-emerald-50/60 text-emerald-800 hover:bg-emerald-100'
|
: 'border-success-border bg-success-muted/60 text-success-muted-foreground hover:bg-success-muted'
|
||||||
}`}
|
}`}
|
||||||
title={t('awrs.autoMatchTitle', { field: selField.toUpperCase(), code: autoMatch.code })}
|
title={t('awrs.autoMatchTitle', { field: selField.toUpperCase(), code: autoMatch.code })}
|
||||||
>
|
>
|
||||||
{autoAlreadyAdded ? '✓ ' : '+ '}
|
{autoAlreadyAdded ? '✓ ' : '+ '}
|
||||||
<span className="font-mono font-semibold">{autoMatch.code}</span>
|
<span className="font-mono font-semibold">{autoMatch.code}</span>
|
||||||
<span className="text-emerald-700"> {t('awrs.fromField', { field: selField })}</span>
|
<span className="text-success-muted-foreground"> {t('awrs.fromField', { field: selField })}</span>
|
||||||
{!autoAlreadyAdded && <span className="block text-[10px] text-emerald-700/80">{t('awrs.autoClickToAdd')}</span>}
|
{!autoAlreadyAdded && <span className="block text-[10px] text-success-muted-foreground/80">{t('awrs.autoClickToAdd')}</span>}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ function cellStatus(r: AwardRef, band: string): CellStatus {
|
|||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
const CELL_STYLE: Record<CellStatus, string> = {
|
const CELL_STYLE: Record<CellStatus, string> = {
|
||||||
validated: 'bg-emerald-500 text-white',
|
validated: 'bg-success text-success-foreground',
|
||||||
confirmed: 'bg-amber-400 text-amber-950',
|
confirmed: 'bg-warning text-warning-foreground',
|
||||||
worked: 'bg-stone-400 text-white',
|
worked: 'bg-muted-foreground text-background',
|
||||||
none: '',
|
none: '',
|
||||||
};
|
};
|
||||||
const CELL_LABEL: Record<CellStatus, string> = { validated: 'V', confirmed: 'C', worked: 'W', none: '' };
|
const CELL_LABEL: Record<CellStatus, string> = { validated: 'V', confirmed: 'C', worked: 'W', none: '' };
|
||||||
@@ -53,15 +53,15 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
|
|||||||
if (total <= 0) return null;
|
if (total <= 0) return null;
|
||||||
return (
|
return (
|
||||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden flex">
|
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden flex">
|
||||||
<div className="bg-emerald-500" style={{ width: `${pct(confirmed, total)}%` }} />
|
<div className="bg-success" style={{ width: `${pct(confirmed, total)}%` }} />
|
||||||
<div className="bg-amber-400/70" style={{ width: `${pct(worked - confirmed, total)}%` }} />
|
<div className="bg-warning/70" style={{ width: `${pct(worked - confirmed, total)}%` }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
|
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
|
||||||
|
|
||||||
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: number) => void; onAwardsChanged?: () => void } = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
||||||
// Computed results are cached per award code — each award is scanned only the
|
// Computed results are cached per award code — each award is scanned only the
|
||||||
@@ -220,7 +220,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
{t('awp.rescan')}
|
{t('awp.rescan')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} />
|
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); onAwardsChanged?.(); }} />
|
||||||
{/* Quick selector — scales when there are many awards. */}
|
{/* Quick selector — scales when there are many awards. */}
|
||||||
{awardList.length > 0 && (
|
{awardList.length > 0 && (
|
||||||
<div className="px-2 py-2 border-b border-border/40">
|
<div className="px-2 py-2 border-b border-border/40">
|
||||||
@@ -255,7 +255,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<span className="font-semibold text-sm">{a.code}</span>
|
<span className="font-semibold text-sm">{a.code}</span>
|
||||||
{r ? (
|
{r ? (
|
||||||
<span className="text-[11px] font-mono text-muted-foreground">
|
<span className="text-[11px] font-mono text-muted-foreground">
|
||||||
<span className="text-emerald-600">{r.confirmed}</span>
|
<span className="text-success">{r.confirmed}</span>
|
||||||
/<span className="text-foreground">{r.worked}</span>
|
/<span className="text-foreground">{r.worked}</span>
|
||||||
{r.total > 0 && <span className="text-muted-foreground/70"> {t('awp.of')} {r.total}</span>}
|
{r.total > 0 && <span className="text-muted-foreground/70"> {t('awp.of')} {r.total}</span>}
|
||||||
</span>
|
</span>
|
||||||
@@ -286,8 +286,8 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-4 text-sm">
|
<div className="mt-1 flex items-center gap-4 text-sm">
|
||||||
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">{t('awp.worked')}</span></span>
|
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">{t('awp.worked')}</span></span>
|
||||||
<span><span className="font-bold text-emerald-600">{current.confirmed}</span> <span className="text-muted-foreground">{t('awp.confirmed')}</span></span>
|
<span><span className="font-bold text-success">{current.confirmed}</span> <span className="text-muted-foreground">{t('awp.confirmed')}</span></span>
|
||||||
<span><span className="font-bold text-sky-600">{current.validated}</span> <span className="text-muted-foreground">{t('awp.validated')}</span></span>
|
<span><span className="font-bold text-info">{current.validated}</span> <span className="text-muted-foreground">{t('awp.validated')}</span></span>
|
||||||
{current.total > 0 && (
|
{current.total > 0 && (
|
||||||
<span className="text-muted-foreground">{t('awp.ofConfirmed', { total: current.total, pct: pct(current.confirmed, current.total) })}</span>
|
<span className="text-muted-foreground">{t('awp.ofConfirmed', { total: current.total, pct: pct(current.confirmed, current.total) })}</span>
|
||||||
)}
|
)}
|
||||||
@@ -303,7 +303,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
{(current.bands ?? []).map((b) => (
|
{(current.bands ?? []).map((b) => (
|
||||||
<div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
|
<div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
|
||||||
<span className="font-mono font-semibold">{b.band}</span>{' '}
|
<span className="font-mono font-semibold">{b.band}</span>{' '}
|
||||||
<span className="text-emerald-600 font-mono">{b.confirmed}</span>
|
<span className="text-success font-mono">{b.confirmed}</span>
|
||||||
<span className="text-muted-foreground font-mono">/{b.worked}</span>
|
<span className="text-muted-foreground font-mono">/{b.worked}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -328,7 +328,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMissing(true)}
|
onClick={() => setShowMissing(true)}
|
||||||
className="flex items-center gap-1 text-xs text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
|
className="flex items-center gap-1 text-xs text-warning-muted-foreground hover:text-warning border border-warning-border bg-warning-muted rounded px-2 py-1"
|
||||||
title={t('awp.missingRefsTitle')}
|
title={t('awp.missingRefsTitle')}
|
||||||
>
|
>
|
||||||
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
|
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
|
||||||
@@ -336,9 +336,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-stone-400" />W</span>
|
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-muted-foreground" />W</span>
|
||||||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-amber-400" />C</span>
|
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-warning" />C</span>
|
||||||
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-emerald-500" />V</span>
|
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-success" />V</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
||||||
<button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.gridView')} onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
|
<button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.gridView')} onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
|
||||||
@@ -370,7 +370,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<td className={cn('sticky left-0 bg-card py-1 pr-3 border-b border-border/30 whitespace-nowrap',
|
<td className={cn('sticky left-0 bg-card py-1 pr-3 border-b border-border/30 whitespace-nowrap',
|
||||||
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
|
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
|
||||||
{statsBandIdx.map((i) => { const c = row.cells[i] ?? 0; return (
|
{statsBandIdx.map((i) => { const c = row.cells[i] ?? 0; return (
|
||||||
<td key={i} className={cn('text-center py-1 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
|
<td key={i} className={cn('text-center py-1 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-success/15 text-success' : 'text-muted-foreground/30')}>{c || ''}</td>
|
||||||
); })}
|
); })}
|
||||||
<td className="text-center py-1 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
|
<td className="text-center py-1 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
|
||||||
<td className="text-center py-1 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
|
<td className="text-center py-1 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
|
||||||
@@ -439,13 +439,13 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
|
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
|
||||||
<td className="py-1 pr-2">
|
<td className="py-1 pr-2">
|
||||||
{!r.worked ? <span className="text-muted-foreground/70">{t('awp.missing')}</span>
|
{!r.worked ? <span className="text-muted-foreground/70">{t('awp.missing')}</span>
|
||||||
: r.validated ? <span className="text-emerald-600">{t('awp.validated')}</span>
|
: r.validated ? <span className="text-success">{t('awp.validated')}</span>
|
||||||
: r.confirmed ? <span className="text-amber-600">{t('awp.confirmed')}</span>
|
: r.confirmed ? <span className="text-warning">{t('awp.confirmed')}</span>
|
||||||
: <span className="text-stone-500">{t('awp.worked')}</span>}
|
: <span className="text-muted-foreground">{t('awp.worked')}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1 font-mono text-muted-foreground">
|
<td className="py-1 font-mono text-muted-foreground">
|
||||||
{r.bands.map((b) => (
|
{r.bands.map((b) => (
|
||||||
<span key={b} className={cn('mr-1', r.validated_bands.includes(b) ? 'text-emerald-600 font-semibold' : r.confirmed_bands.includes(b) && 'text-amber-600 font-semibold')}>{b}</span>
|
<span key={b} className={cn('mr-1', r.validated_bands.includes(b) ? 'text-success font-semibold' : r.confirmed_bands.includes(b) && 'text-warning font-semibold')}>{b}</span>
|
||||||
))}
|
))}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -558,7 +558,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
|
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
|
||||||
<div className="bg-card border border-border rounded-lg shadow-xl w-[860px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
<div className="bg-card border border-border rounded-lg shadow-xl w-[860px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
|
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
|
||||||
<AlertTriangle className="size-4 text-amber-600" />
|
<AlertTriangle className="size-4 text-warning" />
|
||||||
<span className="font-semibold text-sm">{code} — {t('awp.contactsMissingRef')}</span>
|
<span className="font-semibold text-sm">{code} — {t('awp.contactsMissingRef')}</span>
|
||||||
{name && <span className="text-xs text-muted-foreground truncate">{name}</span>}
|
{name && <span className="text-xs text-muted-foreground truncate">{name}</span>}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
@@ -589,7 +589,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
||||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} {t('awp.assignToSelected', { n: sel.size })}
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} {t('awp.assignToSelected', { n: sel.size })}
|
||||||
</Button>
|
</Button>
|
||||||
{msg && <span className="text-[11px] text-emerald-700">{msg}</span>}
|
{msg && <span className="text-[11px] text-success">{msg}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ interface Props {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
side?: 'left' | 'right';
|
side?: 'left' | 'right';
|
||||||
onToggleSide?: () => void;
|
onToggleSide?: () => void;
|
||||||
|
// hideDigital drops every DATA-class (FT8/FT4/JS8/…) spot so the crowded
|
||||||
|
// watering holes don't hog the map. fitToBand overrides zoom so the whole
|
||||||
|
// band edge-to-edge fits the visible height (no scrolling). Both are driven
|
||||||
|
// globally from the band-map tab toolbar.
|
||||||
|
hideDigital?: boolean;
|
||||||
|
fitToBand?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BAND_RANGES: Record<string, [number, number]> = {
|
const BAND_RANGES: Record<string, [number, number]> = {
|
||||||
@@ -55,17 +61,17 @@ const BAND_RANGES: Record<string, [number, number]> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
||||||
'160m': [[1800, 1838, 'fill-emerald-50'], [1838, 1840, 'fill-sky-50'], [1840, 2000, 'fill-amber-50']],
|
'160m': [[1800, 1838, 'fill-success-muted'], [1838, 1840, 'fill-info-muted'], [1840, 2000, 'fill-warning-muted']],
|
||||||
'80m': [[3500, 3580, 'fill-emerald-50'], [3580, 3600, 'fill-sky-50'], [3600, 3800, 'fill-amber-50']],
|
'80m': [[3500, 3580, 'fill-success-muted'], [3580, 3600, 'fill-info-muted'], [3600, 3800, 'fill-warning-muted']],
|
||||||
'60m': [[5350, 5450, 'fill-amber-50']],
|
'60m': [[5350, 5450, 'fill-warning-muted']],
|
||||||
'40m': [[7000, 7040, 'fill-emerald-50'], [7040, 7100, 'fill-sky-50'], [7100, 7200, 'fill-amber-50']],
|
'40m': [[7000, 7040, 'fill-success-muted'], [7040, 7100, 'fill-info-muted'], [7100, 7200, 'fill-warning-muted']],
|
||||||
'30m': [[10100, 10130, 'fill-emerald-50'], [10130, 10150, 'fill-sky-50']],
|
'30m': [[10100, 10130, 'fill-success-muted'], [10130, 10150, 'fill-info-muted']],
|
||||||
'20m': [[14000, 14070, 'fill-emerald-50'], [14070, 14100, 'fill-sky-50'], [14100, 14350, 'fill-amber-50']],
|
'20m': [[14000, 14070, 'fill-success-muted'], [14070, 14100, 'fill-info-muted'], [14100, 14350, 'fill-warning-muted']],
|
||||||
'17m': [[18068, 18095, 'fill-emerald-50'], [18095, 18110, 'fill-sky-50'], [18110, 18168, 'fill-amber-50']],
|
'17m': [[18068, 18095, 'fill-success-muted'], [18095, 18110, 'fill-info-muted'], [18110, 18168, 'fill-warning-muted']],
|
||||||
'15m': [[21000, 21070, 'fill-emerald-50'], [21070, 21150, 'fill-sky-50'], [21150, 21450, 'fill-amber-50']],
|
'15m': [[21000, 21070, 'fill-success-muted'], [21070, 21150, 'fill-info-muted'], [21150, 21450, 'fill-warning-muted']],
|
||||||
'12m': [[24890, 24915, 'fill-emerald-50'], [24915, 24940, 'fill-sky-50'], [24940, 24990, 'fill-amber-50']],
|
'12m': [[24890, 24915, 'fill-success-muted'], [24915, 24940, 'fill-info-muted'], [24940, 24990, 'fill-warning-muted']],
|
||||||
'10m': [[28000, 28070, 'fill-emerald-50'], [28070, 28300, 'fill-sky-50'], [28300, 29700, 'fill-amber-50']],
|
'10m': [[28000, 28070, 'fill-success-muted'], [28070, 28300, 'fill-info-muted'], [28300, 29700, 'fill-warning-muted']],
|
||||||
'6m': [[50000, 50100, 'fill-emerald-50'], [50100, 50500, 'fill-amber-50']],
|
'6m': [[50000, 50100, 'fill-success-muted'], [50100, 50500, 'fill-warning-muted']],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Small coloured dot + label used in the band-map legend strip.
|
// Small coloured dot + label used in the band-map legend strip.
|
||||||
@@ -97,22 +103,22 @@ function statusStyle(s: string): { pill: string; bar: string; line: string; dot:
|
|||||||
// dot = small marker on the freq scale
|
// dot = small marker on the freq scale
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case 'new': return {
|
case 'new': return {
|
||||||
pill: 'bg-rose-50 text-rose-900 border-rose-200 hover:bg-rose-100',
|
pill: 'bg-danger-muted text-danger-muted-foreground border-danger-border hover:bg-danger-muted',
|
||||||
bar: 'bg-rose-500',
|
bar: 'bg-danger',
|
||||||
line: 'stroke-rose-400',
|
line: 'stroke-danger',
|
||||||
dot: 'fill-rose-500',
|
dot: 'fill-danger',
|
||||||
};
|
};
|
||||||
case 'new-band': return {
|
case 'new-band': return {
|
||||||
pill: 'bg-amber-50 text-amber-900 border-amber-200 hover:bg-amber-100',
|
pill: 'bg-warning-muted text-warning-muted-foreground border-warning-border hover:bg-warning-muted',
|
||||||
bar: 'bg-amber-500',
|
bar: 'bg-warning',
|
||||||
line: 'stroke-amber-400',
|
line: 'stroke-warning',
|
||||||
dot: 'fill-amber-500',
|
dot: 'fill-warning',
|
||||||
};
|
};
|
||||||
case 'new-slot': return {
|
case 'new-slot': return {
|
||||||
pill: 'bg-yellow-50 text-yellow-900 border-yellow-200 hover:bg-yellow-100',
|
pill: 'bg-caution-muted text-caution-muted-foreground border-caution-border hover:bg-caution-muted',
|
||||||
bar: 'bg-yellow-500',
|
bar: 'bg-caution',
|
||||||
line: 'stroke-yellow-500',
|
line: 'stroke-caution',
|
||||||
dot: 'fill-yellow-500',
|
dot: 'fill-caution',
|
||||||
};
|
};
|
||||||
case 'worked': return {
|
case 'worked': return {
|
||||||
pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50',
|
pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50',
|
||||||
@@ -147,7 +153,7 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
|||||||
// last; ties broken by closeness to the rig freq).
|
// last; ties broken by closeness to the rig freq).
|
||||||
const MAX_VISIBLE_SPOTS = 30;
|
const MAX_VISIBLE_SPOTS = 30;
|
||||||
|
|
||||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide }: Props) {
|
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const range = BAND_RANGES[band];
|
const range = BAND_RANGES[band];
|
||||||
const segments = SEGMENT_COLORS[band] ?? [];
|
const segments = SEGMENT_COLORS[band] ?? [];
|
||||||
@@ -167,7 +173,12 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
const fallback: [number, number] = range ?? [0, 1];
|
const fallback: [number, number] = range ?? [0, 1];
|
||||||
const [lo, hi] = fallback;
|
const [lo, hi] = fallback;
|
||||||
const span = hi - lo;
|
const span = hi - lo;
|
||||||
const pxPerKHz = PX_PER_KHZ[zoomIdx];
|
// Fit-to-band computes the exact px/kHz so the whole band edge-to-edge fills
|
||||||
|
// the visible height; otherwise use the discrete zoom step.
|
||||||
|
const fitPxPerKHz = fitToBand && containerH > 0 && span > 0
|
||||||
|
? Math.max(0.05, (containerH - TOP_PAD - BOT_PAD) / span)
|
||||||
|
: 0;
|
||||||
|
const pxPerKHz = fitPxPerKHz || PX_PER_KHZ[zoomIdx];
|
||||||
|
|
||||||
// Anti-overlap layout: each label wants to sit at its true freq, but
|
// Anti-overlap layout: each label wants to sit at its true freq, but
|
||||||
// never closer than PILL_H from the previous one. Sorted top-to-bottom
|
// never closer than PILL_H from the previous one. Sorted top-to-bottom
|
||||||
@@ -199,7 +210,8 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
// spots carry no mode word and the band-plan fallback labels them the
|
// spots carry no mode word and the band-plan fallback labels them the
|
||||||
// generic "DATA" rather than "FT8". CW and SSB are always shown in full.
|
// generic "DATA" rather than "FT8". CW and SSB are always shown in full.
|
||||||
const isFlood = (s: Spot) => spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz)) === 'DATA';
|
const isFlood = (s: Spot) => spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz)) === 'DATA';
|
||||||
const ftSpots = inBand.filter(isFlood);
|
// hideDigital removes them entirely; otherwise they're capped below.
|
||||||
|
const ftSpots = hideDigital ? [] : inBand.filter(isFlood);
|
||||||
const otherSpots = inBand.filter((s) => !isFlood(s));
|
const otherSpots = inBand.filter((s) => !isFlood(s));
|
||||||
|
|
||||||
// Rank a DATA spot by usefulness (new entity → unworked → worked); ties
|
// Rank a DATA spot by usefulness (new entity → unworked → worked); ties
|
||||||
@@ -272,12 +284,16 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
out.push({ spot: filtered[i], freqY: desired[i], labelY: centers[i] + shift - PILL_H / 2 });
|
out.push({ spot: filtered[i], freqY: desired[i], labelY: centers[i] + shift - PILL_H / 2 });
|
||||||
}
|
}
|
||||||
const lastLabelBottom = out.length ? out[out.length - 1].labelY + PILL_H : 0;
|
const lastLabelBottom = out.length ? out[out.length - 1].labelY + PILL_H : 0;
|
||||||
|
// In fit mode the band scale exactly fills the viewport (innerH = containerH
|
||||||
|
// − pads); when crowded sub-bands stack labels past the bottom the content
|
||||||
|
// grows and a scroll bar appears — the map defaults to the top so the whole
|
||||||
|
// band is visible, and you scroll down to reach the stacked spots.
|
||||||
return {
|
return {
|
||||||
placed: out,
|
placed: out,
|
||||||
totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD),
|
totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD),
|
||||||
hidden: hiddenCount,
|
hidden: hiddenCount,
|
||||||
};
|
};
|
||||||
}, [spots, range, lo, hi, span, pxPerKHz, containerH, spotStatus, currentFreqHz]);
|
}, [spots, range, lo, hi, span, pxPerKHz, containerH, spotStatus, currentFreqHz, hideDigital]);
|
||||||
|
|
||||||
// freqToY for elements rendered outside the memo (ticks, rig pointer).
|
// freqToY for elements rendered outside the memo (ticks, rig pointer).
|
||||||
// Must mirror the same offset so the rig triangle sits on the right kHz.
|
// Must mirror the same offset so the rig triangle sits on the right kHz.
|
||||||
@@ -288,15 +304,19 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
// frequency actually changes (or band/zoom/size), so tuning follows the rig
|
// frequency actually changes (or band/zoom/size), so tuning follows the rig
|
||||||
// while a stable frequency leaves your manual scroll alone.
|
// while a stable frequency leaves your manual scroll alone.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!range || containerH <= 0 || currentFreqHz <= 0) return;
|
if (!range || containerH <= 0) return;
|
||||||
const kHz = currentFreqHz / 1000;
|
|
||||||
if (kHz < lo || kHz > hi) return;
|
|
||||||
const el = scrollerRef.current;
|
const el = scrollerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
// Fit-to-band: keep the view at the top so the full band is always in frame;
|
||||||
|
// any overflow spots are reached by scrolling down (don't follow the rig).
|
||||||
|
if (fitToBand) { el.scrollTop = 0; return; }
|
||||||
|
if (currentFreqHz <= 0) return;
|
||||||
|
const kHz = currentFreqHz / 1000;
|
||||||
|
if (kHz < lo || kHz > hi) return;
|
||||||
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
|
el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
|
||||||
// freqToY is recomputed each render; intentionally excluded from deps.
|
// freqToY is recomputed each render; intentionally excluded from deps.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz]);
|
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollerRef.current;
|
const el = scrollerRef.current;
|
||||||
@@ -350,13 +370,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
<div className="h-full w-full flex flex-col min-h-0 bg-card">
|
<div className="h-full w-full flex flex-col min-h-0 bg-card">
|
||||||
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
|
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
|
||||||
<span className="flex-1 min-w-0 truncate">{t('bmp.map')} · {band}</span>
|
<span className="flex-1 min-w-0 truncate">{t('bmp.map')} · {band}</span>
|
||||||
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={zoomIdx === 0}
|
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={fitToBand || zoomIdx === 0}
|
||||||
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||||
title={t('bmp.zoomOut')}>
|
title={t('bmp.zoomOut')}>
|
||||||
<Minus className="size-3" />
|
<Minus className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{pxPerKHz}px/kHz</span>
|
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
|
||||||
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={zoomIdx === PX_PER_KHZ.length - 1}
|
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
|
||||||
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||||
title={t('bmp.zoomIn')}>
|
title={t('bmp.zoomIn')}>
|
||||||
<Plus className="size-3" />
|
<Plus className="size-3" />
|
||||||
@@ -500,14 +520,14 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
</div>
|
</div>
|
||||||
{/* Colour legend — what each pill colour means. */}
|
{/* Colour legend — what each pill colour means. */}
|
||||||
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
||||||
<LegendDot cls="bg-rose-400" label={t('bmp.legendNewDxcc')} />
|
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
||||||
<LegendDot cls="bg-amber-400" label={t('bmp.legendNewBand')} />
|
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
||||||
<LegendDot cls="bg-yellow-300" label={t('bmp.legendNewSlot')} />
|
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
|
||||||
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} />
|
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
||||||
{t('bmp.footerHint')}
|
{t('bmp.footerHint')}
|
||||||
{hidden > 0 && <span className="text-amber-600"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,22 +51,27 @@ function classMatchesMode(cls: string, mode: string): boolean {
|
|||||||
return u !== '' && u !== 'CW' && !PHONE_MODES.has(u);
|
return u !== '' && u !== 'CW' && !PHONE_MODES.has(u);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dedicated matrix palette (--mx-* tokens, per theme in style.css): a 3-level
|
||||||
|
// ramp per hue so confirmed / worked / not-worked stay distinguishable on both
|
||||||
|
// light and dark surfaces (the generic status -muted fills were too dark on the
|
||||||
|
// dark themes to tell "worked" from "not worked" apart). Light-warm reproduces
|
||||||
|
// the original emerald/indigo/stone colours exactly.
|
||||||
const STATUS_CLASSES: Record<string, string> = {
|
const STATUS_CLASSES: Record<string, string> = {
|
||||||
call_c: 'bg-emerald-700 hover:bg-emerald-600',
|
call_c: 'bg-mx-call-conf',
|
||||||
call_w: 'bg-emerald-300 hover:bg-emerald-200',
|
call_w: 'bg-mx-call-work',
|
||||||
dxcc_c: 'bg-indigo-800 hover:bg-indigo-700',
|
dxcc_c: 'bg-mx-dx-conf',
|
||||||
dxcc_w: 'bg-indigo-300 hover:bg-indigo-200',
|
dxcc_w: 'bg-mx-dx-work',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Legend entries, in the same colour order as the cells. swatch = the
|
// Legend entries, in the same colour order as the cells. swatch = the
|
||||||
// background class (or a special ring marker for the current-entry cell).
|
// background class (or a special ring marker for the current-entry cell).
|
||||||
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
|
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
|
||||||
{ swatch: 'bg-emerald-700', label: 'Call confirmed' },
|
{ swatch: 'bg-mx-call-conf', label: 'Call confirmed' },
|
||||||
{ swatch: 'bg-emerald-300', label: 'Call worked' },
|
{ swatch: 'bg-mx-call-work', label: 'Call worked' },
|
||||||
{ swatch: 'bg-indigo-800', label: 'Entity confirmed' },
|
{ swatch: 'bg-mx-dx-conf', label: 'Entity confirmed' },
|
||||||
{ swatch: 'bg-indigo-300', label: 'Entity worked' },
|
{ swatch: 'bg-mx-dx-work', label: 'Entity worked' },
|
||||||
{ swatch: 'bg-stone-200', label: 'Not worked' },
|
{ swatch: 'bg-mx-none', label: 'Not worked' },
|
||||||
{ swatch: 'bg-stone-200', ring: true, label: 'Current entry' },
|
{ swatch: 'bg-mx-none', ring: true, label: 'Current entry' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function cellTitle(band: string, cls: string, status: string, current: boolean): string {
|
function cellTitle(band: string, cls: string, status: string, current: boolean): string {
|
||||||
@@ -128,18 +133,18 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-4 px-3 py-2 flex-wrap shrink-0',
|
'flex items-center gap-4 px-3 py-2 flex-wrap shrink-0',
|
||||||
newOne && 'bg-gradient-to-br from-amber-100 to-amber-200 rounded-lg',
|
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-[220px]">
|
<div className="flex items-center gap-2 min-w-[220px]">
|
||||||
{newOne ? (
|
{newOne ? (
|
||||||
<>
|
<>
|
||||||
<Badge className="bg-amber-800 text-amber-50 gap-1 px-3 py-1 text-[11px]">
|
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
||||||
<Star className="size-3 fill-current" />
|
<Star className="size-3 fill-current" />
|
||||||
NEW ONE
|
NEW ONE
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-xs text-amber-900">
|
<span className="text-xs text-warning-muted-foreground">
|
||||||
<strong className="text-amber-950 font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
<strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
||||||
{' '}· never worked this entity
|
{' '}· never worked this entity
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
@@ -161,10 +166,10 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
</span>
|
</span>
|
||||||
{(newBand || newMode || newBandMode || newSlot) && (
|
{(newBand || newMode || newBandMode || newSlot) && (
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
{newBandMode && <Badge className="bg-amber-500 text-white px-1.5 py-0 text-[10px]">New Band & Mode</Badge>}
|
{newBandMode && <Badge className="bg-warning text-warning-foreground px-1.5 py-0 text-[10px]">New Band & Mode</Badge>}
|
||||||
{newBand && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Band</Badge>}
|
{newBand && <Badge className="bg-warning text-warning-foreground px-1.5 py-0 text-[10px]">New Band</Badge>}
|
||||||
{newMode && <Badge className="bg-amber-600 text-white px-1.5 py-0 text-[10px]">New Mode</Badge>}
|
{newMode && <Badge className="bg-warning text-warning-foreground px-1.5 py-0 text-[10px]">New Mode</Badge>}
|
||||||
{newSlot && <Badge className="bg-sky-600 text-white px-1.5 py-0 text-[10px]">New Slot</Badge>}
|
{newSlot && <Badge className="bg-info text-info-foreground px-1.5 py-0 text-[10px]">New Slot</Badge>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -220,8 +225,8 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
title={cellTitle(b.tag, cls, st, isCurrent)}
|
title={cellTitle(b.tag, cls, st, isCurrent)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-[28px] h-[24px] rounded transition-colors p-0',
|
'w-[28px] h-[24px] rounded transition-colors p-0',
|
||||||
st ? STATUS_CLASSES[st] : 'bg-stone-200 hover:bg-stone-300',
|
st ? STATUS_CLASSES[st] : 'bg-mx-none',
|
||||||
isCurrent && 'ring-2 ring-amber-500 ring-inset',
|
isCurrent && 'ring-2 ring-warning ring-inset',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -240,7 +245,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
className={cn(
|
className={cn(
|
||||||
'inline-block size-3 rounded shrink-0',
|
'inline-block size-3 rounded shrink-0',
|
||||||
l.swatch,
|
l.swatch,
|
||||||
l.ring && 'ring-2 ring-amber-500 ring-inset',
|
l.ring && 'ring-2 ring-warning ring-inset',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{l.label}
|
{l.label}
|
||||||
|
|||||||
@@ -49,6 +49,24 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
|
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
|
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
|
||||||
|
// Contest
|
||||||
|
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
|
||||||
|
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
|
||||||
|
{ id: 'stx_string', label: 'bulk.fStxString', group: 'Contest', kind: 'text' },
|
||||||
|
{ id: 'arrl_sect', label: 'bulk.fArrlSect', group: 'Contest', kind: 'text', upper: true },
|
||||||
|
{ id: 'precedence', label: 'bulk.fPrecedence', group: 'Contest', kind: 'text', upper: true },
|
||||||
|
{ id: 'class', label: 'bulk.fClass', group: 'Contest', kind: 'text', upper: true },
|
||||||
|
// Propagation / satellite
|
||||||
|
{ id: 'prop_mode', label: 'bulk.fPropMode', group: 'Propagation', kind: 'text', upper: true },
|
||||||
|
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
|
||||||
|
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
|
||||||
|
// Contacted station (activation refs / SIG)
|
||||||
|
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
|
{ id: 'sota_ref', label: 'bulk.fSotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
|
{ id: 'wwff_ref', label: 'bulk.fWwffRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
|
{ id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
|
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
|
||||||
|
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
|
||||||
// Misc
|
// Misc
|
||||||
{ 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' },
|
||||||
@@ -65,11 +83,14 @@ const STATUS_VALUES: { v: string; label: string }[] = [
|
|||||||
{ v: '_', label: 'bulk.statusBlank' },
|
{ v: '_', label: 'bulk.statusBlank' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const GROUPS = ['QSL / upload', 'My station', 'Misc'];
|
const GROUPS = ['QSL / upload', 'My station', 'Contacted station', 'Contest', 'Propagation', 'Misc'];
|
||||||
// Maps the internal group key → its i18n label key.
|
// Maps the internal group key → its i18n label key.
|
||||||
const GROUP_LABELS: Record<string, string> = {
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
'QSL / upload': 'bulk.groupQsl',
|
'QSL / upload': 'bulk.groupQsl',
|
||||||
'My station': 'bulk.groupMyStation',
|
'My station': 'bulk.groupMyStation',
|
||||||
|
'Contacted station': 'bulk.groupContacted',
|
||||||
|
'Contest': 'bulk.groupContest',
|
||||||
|
'Propagation': 'bulk.groupPropagation',
|
||||||
'Misc': 'bulk.groupMisc',
|
'Misc': 'bulk.groupMisc',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,8 +151,11 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|||||||
{GROUPS.map((g) => (
|
{GROUPS.map((g) => (
|
||||||
<div key={g}>
|
<div key={g}>
|
||||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{t(GROUP_LABELS[g])}</div>
|
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{t(GROUP_LABELS[g])}</div>
|
||||||
{FIELDS.filter((f) => f.group === g).map((f) => (
|
{FIELDS.filter((f) => f.group === g)
|
||||||
<SelectItem key={f.id} value={f.id}>{t(f.label)}</SelectItem>
|
.map((f) => ({ f, txt: t(f.label) }))
|
||||||
|
.sort((a, b) => a.txt.localeCompare(b.txt))
|
||||||
|
.map(({ f, txt }) => (
|
||||||
|
<SelectItem key={f.id} value={f.id}>{txt}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -160,7 +184,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|||||||
{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}</span> {t('bulk.onQsos', { n: ids.length })}
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="text-xs text-rose-700">{error}</div>}
|
{error && <div className="text-xs text-danger">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -76,32 +76,32 @@ export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
|||||||
<table className="w-full text-[11px] border-collapse">
|
<table className="w-full text-[11px] border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.dateUtc')}</th>
|
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.dateUtc')}</th>
|
||||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.band')}</th>
|
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.band')}</th>
|
||||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.mode')}</th>
|
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">{t('chp.mode')}</th>
|
||||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST tx</th>
|
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST tx</th>
|
||||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST rx</th>
|
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST rx</th>
|
||||||
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">QSL</th>
|
<th className="sticky top-0 bg-muted text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">QSL</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.map((e) => (
|
{entries.map((e) => (
|
||||||
<tr key={e.id} className="hover:bg-stone-50">
|
<tr key={e.id} className="hover:bg-card">
|
||||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{fmtDateTime(e.qso_date)}</td>
|
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{fmtDateTime(e.qso_date)}</td>
|
||||||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
|
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
|
||||||
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-accent text-accent-foreground">{e.band}</span>
|
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-accent text-accent-foreground">{e.band}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
|
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
|
||||||
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-emerald-100 text-emerald-700">{e.mode}</span>
|
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-success-muted text-success-muted-foreground">{e.mode}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_sent ?? ''}</td>
|
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_sent ?? ''}</td>
|
||||||
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td>
|
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td>
|
||||||
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
|
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
|
||||||
{e.lotw_rcvd === 'Y' && (
|
{e.lotw_rcvd === 'Y' && (
|
||||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-blue-600 mr-0.5" title={t('chp.lotwRcvd')}>L</span>
|
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-info-foreground bg-info mr-0.5" title={t('chp.lotwRcvd')}>L</span>
|
||||||
)}
|
)}
|
||||||
{e.qsl_rcvd === 'Y' && (
|
{e.qsl_rcvd === 'Y' && (
|
||||||
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-emerald-600 mr-0.5" title={t('chp.bureauRcvd')}>B</span>
|
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-success-foreground bg-success mr-0.5" title={t('chp.bureauRcvd')}>B</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
|||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
<div className="h-full flex flex-col 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 shrink-0">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30 shrink-0">
|
||||||
<MessageSquare className="size-4 text-sky-600" />
|
<MessageSquare className="size-4 text-info" />
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('chatp.chat')}</span>
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('chatp.chat')}</span>
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
{/* Online count — hover to see who's connected. */}
|
{/* Online count — hover to see who's connected. */}
|
||||||
@@ -66,7 +66,7 @@ export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
|||||||
const mine = m.operator.toUpperCase() === me;
|
const mine = m.operator.toUpperCase() === me;
|
||||||
return (
|
return (
|
||||||
<div key={m.id} className={cn('flex flex-col', mine && 'items-end')}>
|
<div key={m.id} className={cn('flex flex-col', mine && 'items-end')}>
|
||||||
<div className={cn('max-w-[85%] rounded-lg px-2 py-1', mine ? 'bg-sky-100 text-sky-900' : 'bg-muted')}>
|
<div className={cn('max-w-[85%] rounded-lg px-2 py-1', mine ? 'bg-info-muted text-info-muted-foreground' : 'bg-muted')}>
|
||||||
{!mine && <span className="font-mono font-bold text-[10px] text-primary mr-1">{m.operator}</span>}
|
{!mine && <span className="font-mono font-bold text-[10px] text-primary mr-1">{m.operator}</span>}
|
||||||
<span className="whitespace-pre-wrap break-words">{m.message}</span>
|
<span className="whitespace-pre-wrap break-words">{m.message}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
AllCommunityModule, ModuleRegistry,
|
||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
|
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 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -18,27 +19,7 @@ type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
|||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
const hamlogTheme = themeQuartz.withParams({
|
const hamlogTheme = hamlogGridTheme;
|
||||||
fontFamily: 'inherit',
|
|
||||||
fontSize: 12.5,
|
|
||||||
backgroundColor: '#faf6ea',
|
|
||||||
foregroundColor: '#2a2419',
|
|
||||||
headerBackgroundColor: '#e8dfc9',
|
|
||||||
headerTextColor: '#5a4f3a',
|
|
||||||
headerFontWeight: 600,
|
|
||||||
oddRowBackgroundColor: '#f5efe0',
|
|
||||||
rowHoverColor: '#ecdcb4',
|
|
||||||
selectedRowBackgroundColor: '#f0d9a8',
|
|
||||||
borderColor: '#c8b994',
|
|
||||||
rowBorder: { color: '#d8c9a8', width: 1 },
|
|
||||||
columnBorder: { color: '#d8c9a8', width: 1 },
|
|
||||||
cellHorizontalPadding: 10,
|
|
||||||
rowHeight: 30,
|
|
||||||
headerHeight: 32,
|
|
||||||
spacing: 4,
|
|
||||||
accentColor: '#b8410c',
|
|
||||||
iconSize: 12,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ClusterSpot = {
|
export type ClusterSpot = {
|
||||||
source_id: number;
|
source_id: number;
|
||||||
@@ -143,11 +124,16 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
// New DXCC entity → fill the whole cell (no padded pill, so calls stay
|
// Only STATUS calls get a colour: new DXCC entity → filled cell (no padded
|
||||||
// aligned with non-new rows). Text colour also flags worked-call vs new-call.
|
// pill, so calls stay aligned), worked-call → blue. A plain spot inherits the
|
||||||
cellStyle: (p: any): any => (statusFor(p)?.status === 'new'
|
// theme's normal text colour (var(--foreground)) so callsigns blend in with
|
||||||
? { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 }
|
// the rest of the row across every theme instead of always shouting orange.
|
||||||
: { color: statusFor(p)?.worked_call ? '#0369a1' : '#b8410c', fontWeight: 700 }),
|
cellStyle: (p: any): any => {
|
||||||
|
const s = statusFor(p);
|
||||||
|
if (s?.status === 'new') return { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 };
|
||||||
|
if (s?.worked_call) return { color: '#0369a1', fontWeight: 700 };
|
||||||
|
return { fontWeight: 700 };
|
||||||
|
},
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
return s?.status === 'new' ? t('clg2.tipNewDxcc', { country: s?.country ?? '' }) : s?.worked_call ? t('clg2.tipWorkedCall') : undefined;
|
return s?.status === 'new' ? t('clg2.tipNewDxcc', { country: s?.country ?? '' }) : s?.worked_call ? t('clg2.tipWorkedCall') : undefined;
|
||||||
|
|||||||
@@ -85,9 +85,9 @@ export function ContestPanel({ session, onChange }: {
|
|||||||
{/* Setup card. */}
|
{/* Setup card. */}
|
||||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/60 bg-muted/30">
|
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/60 bg-muted/30">
|
||||||
<Trophy className="size-4 text-amber-500" />
|
<Trophy className="size-4 text-warning" />
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('ctp.contestSetup')}</span>
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('ctp.contestSetup')}</span>
|
||||||
{session.active && <span className="ml-auto rounded bg-amber-500 px-2 py-0.5 text-[10px] font-black uppercase tracking-wider text-white">{t('ctp.live')}</span>}
|
{session.active && <span className="ml-auto rounded bg-warning px-2 py-0.5 text-[10px] font-black uppercase tracking-wider text-warning-foreground">{t('ctp.live')}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -150,13 +150,13 @@ export function ContestPanel({ session, onChange }: {
|
|||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
{session.active ? (
|
{session.active ? (
|
||||||
<button type="button" onClick={() => onChange({ active: false })}
|
<button type="button" onClick={() => onChange({ active: false })}
|
||||||
className="rounded-md border border-rose-500/60 bg-rose-500/10 px-4 py-1.5 text-sm font-bold text-rose-600 hover:bg-rose-500/20">
|
className="rounded-md border border-danger/60 bg-danger/10 px-4 py-1.5 text-sm font-bold text-danger hover:bg-danger/20">
|
||||||
{t('ctp.stopContest')}
|
{t('ctp.stopContest')}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" disabled={!session.code}
|
<button type="button" disabled={!session.code}
|
||||||
onClick={() => onChange({ active: true, startISO: session.startISO || new Date().toISOString() })}
|
onClick={() => onChange({ active: true, startISO: session.startISO || new Date().toISOString() })}
|
||||||
className="rounded-md bg-amber-500 px-4 py-1.5 text-sm font-bold text-white hover:bg-amber-600 disabled:opacity-40">
|
className="rounded-md bg-warning px-4 py-1.5 text-sm font-bold text-warning-foreground hover:bg-warning disabled:opacity-40">
|
||||||
{t('ctp.startContest')}
|
{t('ctp.startContest')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -126,13 +126,13 @@ export function DuplicatesModal({ onClose, onDeleted }: { onClose: () => void; o
|
|||||||
{g.qsos.map((q, i) => {
|
{g.qsos.map((q, i) => {
|
||||||
const checked = sel.has(q.id);
|
const checked = sel.has(q.id);
|
||||||
return (
|
return (
|
||||||
<tr key={q.id} className={cn('border-t border-border/40', checked && 'bg-rose-500/10')}>
|
<tr key={q.id} className={cn('border-t border-border/40', checked && 'bg-danger/10')}>
|
||||||
<td className="text-center py-1">
|
<td className="text-center py-1">
|
||||||
<input type="checkbox" checked={checked} onChange={() => toggle(q.id)} />
|
<input type="checkbox" checked={checked} onChange={() => toggle(q.id)} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1 font-mono whitespace-nowrap">
|
<td className="px-2 py-1 font-mono whitespace-nowrap">
|
||||||
{fmtDate(q.qso_date)}
|
{fmtDate(q.qso_date)}
|
||||||
{i === 0 && <span className="ml-1.5 text-[9px] uppercase tracking-wider text-emerald-600">{t('dup.keep')}</span>}
|
{i === 0 && <span className="ml-1.5 text-[9px] uppercase tracking-wider text-success">{t('dup.keep')}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1 font-mono">{fmtFreq(q.freq_hz)}</td>
|
<td className="px-2 py-1 font-mono">{fmtFreq(q.freq_hz)}</td>
|
||||||
<td className="px-2 py-1 font-mono">{q.rst_sent || '—'}/{q.rst_rcvd || '—'}</td>
|
<td className="px-2 py-1 font-mono">{q.rst_sent || '—'}/{q.rst_rcvd || '—'}</td>
|
||||||
@@ -151,7 +151,7 @@ export function DuplicatesModal({ onClose, onDeleted }: { onClose: () => void; o
|
|||||||
<div className="flex items-center gap-2 px-5 py-3 border-t border-border">
|
<div className="flex items-center gap-2 px-5 py-3 border-t border-border">
|
||||||
<button type="button" onClick={onClose} className="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted">{t('dup.close')}</button>
|
<button type="button" onClick={onClose} className="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted">{t('dup.close')}</button>
|
||||||
<button type="button" onClick={doDelete} disabled={busy || sel.size === 0}
|
<button type="button" onClick={doDelete} disabled={busy || sel.size === 0}
|
||||||
className="ml-auto inline-flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-1.5 text-sm font-bold text-white hover:bg-red-700 disabled:opacity-40">
|
className="ml-auto inline-flex items-center gap-1.5 rounded-md bg-destructive px-3 py-1.5 text-sm font-bold text-destructive-foreground hover:bg-destructive disabled:opacity-40">
|
||||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2 className="size-3.5" />}
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2 className="size-3.5" />}
|
||||||
{t('dup.deleteSel', { n: sel.size })}
|
{t('dup.deleteSel', { n: sel.size })}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
|
|||||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
|
||||||
<Mic className="size-3.5 text-primary" />
|
<Mic className="size-3.5 text-primary" />
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wider">{t('dvkp.voiceKeyer')}</span>
|
||||||
<span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} />
|
<span className={cn('size-2 rounded-full', status.playing ? 'bg-warning animate-pulse' : 'bg-success')} />
|
||||||
{status.playing && <span className="text-[10px] text-amber-600 font-medium">tx...</span>}
|
{status.playing && <span className="text-[10px] text-warning font-medium">tx...</span>}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
|
||||||
<Square className="size-3" /> {t('dvkp.stop')}
|
<Square className="size-3" /> {t('dvkp.stop')}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||||
import { GetActiveProfile, SaveProfile, DownloadAllReferenceLists } from '../../wailsjs/go/main/App';
|
import { GetActiveProfile, SaveProfile, DownloadAllReferenceLists } from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
|||||||
// (no callsign configured yet). It writes straight into the active profile, so
|
// (no callsign configured yet). It writes straight into the active profile, so
|
||||||
// OpsLog has a valid station before any QSO is logged. Not dismissable.
|
// OpsLog has a valid station before any QSO is logged. Not dismissable.
|
||||||
export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
||||||
const { t } = useI18n();
|
const { t, lang, setLang } = useI18n();
|
||||||
const [p, setP] = useState<Profile | null>(null);
|
const [p, setP] = useState<Profile | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
@@ -68,6 +68,22 @@ export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-2xl p-6 animate-in fade-in zoom-in-95">
|
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-2xl p-6 animate-in fade-in zoom-in-95">
|
||||||
|
{/* Language chooser — lives here (and not only in the localStorage-backed
|
||||||
|
first-launch flag gate) so a fresh setup always offers EN/FR, like the
|
||||||
|
station identity below. */}
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
{([['en', FlagGB, 'English'], ['fr', FlagFR, 'Français']] as [Lang, typeof FlagGB, string][]).map(([code, Flag, label]) => (
|
||||||
|
<button key={code} type="button" onClick={() => setLang(code)}
|
||||||
|
className={cn('flex items-center gap-2 px-3 py-1.5 text-sm font-medium border-l border-border first:border-l-0 transition-colors',
|
||||||
|
lang === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
<Flag className="w-5 rounded-[2px] border border-border/30" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Radio className="size-5 text-primary" />
|
<Radio className="size-5 text-primary" />
|
||||||
<h2 className="text-lg font-semibold">{t('frm.welcome')}</h2>
|
<h2 className="text-lg font-semibold">{t('frm.welcome')}</h2>
|
||||||
@@ -77,10 +93,10 @@ export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
|
<div className="grid grid-cols-[120px_1fr] gap-x-3 gap-y-2.5 items-center">
|
||||||
<Label className="text-sm">{t('frm.callsign')} <span className="text-red-500">*</span></Label>
|
<Label className="text-sm">{t('frm.callsign')} <span className="text-destructive">*</span></Label>
|
||||||
<Input autoFocus className="h-9 font-mono uppercase" placeholder="F4BPO" value={p?.callsign ?? ''} onChange={(e) => set({ callsign: e.target.value })} />
|
<Input autoFocus className="h-9 font-mono uppercase" placeholder="F4BPO" value={p?.callsign ?? ''} onChange={(e) => set({ callsign: e.target.value })} />
|
||||||
|
|
||||||
<Label className="text-sm">{t('frm.locator')} <span className="text-red-500">*</span></Label>
|
<Label className="text-sm">{t('frm.locator')} <span className="text-destructive">*</span></Label>
|
||||||
<Input className="h-9 font-mono uppercase" placeholder="JN03" value={p?.my_grid ?? ''} onChange={(e) => set({ my_grid: e.target.value })} />
|
<Input className="h-9 font-mono uppercase" placeholder="JN03" value={p?.my_grid ?? ''} onChange={(e) => set({ my_grid: e.target.value })} />
|
||||||
|
|
||||||
<Label className="text-sm">{t('frm.operator')}</Label>
|
<Label className="text-sm">{t('frm.operator')}</Label>
|
||||||
@@ -104,10 +120,10 @@ export function FirstRunModal({ onDone }: { onDone: () => void }) {
|
|||||||
{refsState === 'loading' ? t('frm.downloading') : refsState === 'done' ? t('frm.reDownload') : t('frm.download')}
|
{refsState === 'loading' ? t('frm.downloading') : refsState === 'done' ? t('frm.reDownload') : t('frm.download')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{refsMsg && <div className={cn('text-[11px] mt-2', refsState === 'done' ? 'text-emerald-700' : 'text-red-600')}>{refsMsg}</div>}
|
{refsMsg && <div className={cn('text-[11px] mt-2', refsState === 'done' ? 'text-success' : 'text-destructive')}>{refsMsg}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{err && <div className="mt-3 text-xs text-red-600">{err}</div>}
|
{err && <div className="mt-3 text-xs text-destructive">{err}</div>}
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-end gap-2">
|
<div className="mt-5 flex items-center justify-end gap-2">
|
||||||
{!canSave && <span className="text-[11px] text-muted-foreground mr-auto">{t('frm.required')}</span>}
|
{!canSave && <span className="text-[11px] text-muted-foreground mr-auto">{t('frm.required')}</span>}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||||
FlexMox, FlexAmpOperate,
|
FlexMox, FlexAmpOperate,
|
||||||
GetPGXLStatus, PGXLSetFanMode,
|
GetPGXLStatus, PGXLSetFanMode,
|
||||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit,
|
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
||||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||||
@@ -30,8 +30,10 @@ type FlexState = {
|
|||||||
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
|
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
|
||||||
amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
|
amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
|
||||||
meters?: Meter[];
|
meters?: Meter[];
|
||||||
|
slices?: FlexSlice[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FlexSlice = { index: number; letter: string; freq_hz: number; mode?: string; band?: string; active: boolean; tx: boolean };
|
||||||
type Meter = { id: number; src?: string; name?: string; unit?: string; value: number; lo: number; hi: number };
|
type Meter = { id: number; src?: string; name?: string; unit?: string; value: number; lo: number; hi: number };
|
||||||
|
|
||||||
const ZERO: FlexState = {
|
const ZERO: FlexState = {
|
||||||
@@ -79,7 +81,7 @@ function Slider({ value, onChange, disabled, accent = '#16a34a', step = 1, max =
|
|||||||
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||||
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
||||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
|
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
|
||||||
'[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer')}
|
'[&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer')}
|
||||||
style={{ background: `linear-gradient(to right, ${accent} ${pct}%, #d8cfb8 ${pct}%)`, borderColor: accent }}
|
style={{ background: `linear-gradient(to right, ${accent} ${pct}%, #d8cfb8 ${pct}%)`, borderColor: accent }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -107,10 +109,10 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
|||||||
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber';
|
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber';
|
||||||
}) {
|
}) {
|
||||||
const onCls = {
|
const onCls = {
|
||||||
emerald: 'bg-emerald-600 border-emerald-600 text-white',
|
emerald: 'bg-success border-success text-success-foreground',
|
||||||
violet: 'bg-violet-600 border-violet-600 text-white',
|
violet: 'bg-info border-info text-info-foreground',
|
||||||
cyan: 'bg-cyan-600 border-cyan-600 text-white',
|
cyan: 'bg-info border-info text-info-foreground',
|
||||||
amber: 'bg-amber-500 border-amber-500 text-white',
|
amber: 'bg-warning border-warning text-warning-foreground',
|
||||||
}[accent];
|
}[accent];
|
||||||
return (
|
return (
|
||||||
<button type="button" onClick={onClick} disabled={disabled}
|
<button type="button" onClick={onClick} disabled={disabled}
|
||||||
@@ -273,13 +275,46 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
|
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
|
||||||
!st.available ? 'bg-slate-700 text-slate-300'
|
!st.available ? 'bg-muted text-muted-foreground'
|
||||||
: st.transmitting ? 'bg-rose-500 text-white shadow-[0_0_16px] shadow-rose-500/60' : 'bg-emerald-500 text-white')}>
|
: st.transmitting ? 'bg-danger text-danger-foreground shadow-[0_0_16px] shadow-danger/60' : 'bg-success text-success-foreground')}>
|
||||||
<span className="size-2 rounded-full bg-current" />
|
<span className="size-2 rounded-full bg-current" />
|
||||||
{!st.available ? t('flxp.offline') : st.transmitting ? 'TX' : 'RX'}
|
{!st.available ? t('flxp.offline') : st.transmitting ? 'TX' : 'RX'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Slices A/B/C/D — every in-use receiver. Click one to make it the
|
||||||
|
ACTIVE slice: the main frequency, mode, DSP and spot-clicks all follow
|
||||||
|
it. The active slice is highlighted; the TX slice is flagged. */}
|
||||||
|
{!!st.slices && st.slices.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{st.slices.map((sl) => (
|
||||||
|
<button key={sl.index} type="button" disabled={off}
|
||||||
|
onClick={() => FlexSetActiveSlice(sl.index).catch(() => {})}
|
||||||
|
title={t('flxp.sliceHint')}
|
||||||
|
className={cn('flex items-center gap-2 rounded-lg border px-3 py-1.5 transition-colors disabled:opacity-40',
|
||||||
|
sl.active ? 'border-info bg-info/10 ring-1 ring-info' : 'border-border bg-card hover:bg-muted')}>
|
||||||
|
<span className={cn('inline-flex size-6 items-center justify-center rounded-md text-sm font-extrabold',
|
||||||
|
sl.active ? 'bg-info text-info-foreground' : 'bg-muted text-muted-foreground')}>{sl.letter}</span>
|
||||||
|
<span className="flex items-baseline gap-1.5">
|
||||||
|
<span className="font-mono font-bold tabular-nums text-sm leading-none">{(sl.freq_hz / 1e6).toFixed(3)}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground uppercase leading-none">{sl.mode}</span>
|
||||||
|
{sl.band ? <span className="text-[10px] text-muted-foreground leading-none">{sl.band}</span> : null}
|
||||||
|
</span>
|
||||||
|
{/* TX badge — click a non-TX slice's badge to move TX onto it
|
||||||
|
(e.g. transmit on the active slice). stopPropagation so it
|
||||||
|
doesn't also fire the outer "make active" click. */}
|
||||||
|
<span role="button" tabIndex={-1}
|
||||||
|
onClick={(e) => { e.stopPropagation(); if (!sl.tx && !off) FlexSetTXSlice(sl.index).catch(() => {}); }}
|
||||||
|
title={sl.tx ? t('flxp.txSlice') : t('flxp.setTxSlice')}
|
||||||
|
className={cn('rounded text-[10px] font-bold px-1',
|
||||||
|
sl.tx ? 'bg-danger text-danger-foreground' : 'border border-danger/50 text-danger cursor-pointer hover:bg-danger/10')}>
|
||||||
|
TX
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{off && (
|
{off && (
|
||||||
<div className="text-center text-sm text-muted-foreground py-6">
|
<div className="text-center text-sm text-muted-foreground py-6">
|
||||||
{t('flxp.waiting')}
|
{t('flxp.waiting')}
|
||||||
@@ -304,13 +339,13 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
<button type="button" disabled={off}
|
<button type="button" disabled={off}
|
||||||
onClick={() => change('tune', !st.tune, () => FlexTune(!st.tune))}
|
onClick={() => change('tune', !st.tune, () => FlexTune(!st.tune))}
|
||||||
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
st.tune ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50' : 'bg-card text-amber-700 border-amber-400 hover:bg-amber-50')}>
|
st.tune ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
TUNE
|
TUNE
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={off}
|
<button type="button" disabled={off}
|
||||||
onClick={() => change('transmitting', !st.transmitting, () => FlexMox(!st.transmitting))}
|
onClick={() => change('transmitting', !st.transmitting, () => FlexMox(!st.transmitting))}
|
||||||
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
st.transmitting ? 'bg-rose-600 text-white border-rose-600 shadow-[0_0_14px] shadow-rose-600/50' : 'bg-card text-rose-700 border-rose-400 hover:bg-rose-50')}>
|
st.transmitting ? 'bg-danger text-danger-foreground border-danger shadow-[0_0_14px] shadow-danger/50' : 'bg-card text-danger border-danger hover:bg-danger-muted')}>
|
||||||
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
|
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,7 +354,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
title={t('flxp.splitHint')}
|
title={t('flxp.splitHint')}
|
||||||
onClick={() => change('split', !st.split, () => FlexSetSplit(!st.split))}
|
onClick={() => change('split', !st.split, () => FlexSetSplit(!st.split))}
|
||||||
className={cn('px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
className={cn('px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
st.split ? 'bg-sky-600 text-white border-sky-600 shadow-[0_0_12px] shadow-sky-600/50' : 'bg-card text-sky-700 border-sky-400 hover:bg-sky-50')}>
|
st.split ? 'bg-info text-info-foreground border-info shadow-[0_0_12px] shadow-info/50' : 'bg-card text-info border-info hover:bg-info-muted')}>
|
||||||
SPLIT
|
SPLIT
|
||||||
</button>
|
</button>
|
||||||
{st.split && !!st.tx_freq_hz && (
|
{st.split && !!st.tx_freq_hz && (
|
||||||
@@ -414,7 +449,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
title={st.mute ? t('flxp.muted') : t('flxp.mute')}
|
title={st.mute ? t('flxp.muted') : t('flxp.mute')}
|
||||||
onClick={() => change('mute', !st.mute, () => FlexSetMute(!st.mute))}
|
onClick={() => change('mute', !st.mute, () => FlexSetMute(!st.mute))}
|
||||||
className={cn('shrink-0 rounded p-1 transition-colors disabled:opacity-30',
|
className={cn('shrink-0 rounded p-1 transition-colors disabled:opacity-30',
|
||||||
st.mute ? 'bg-red-600 text-white' : 'text-muted-foreground hover:bg-muted')}>
|
st.mute ? 'bg-destructive text-destructive-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||||
{st.mute ? <VolumeX className="size-3.5" /> : <Volume2 className="size-3.5" />}
|
{st.mute ? <VolumeX className="size-3.5" /> : <Volume2 className="size-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
<Slider value={st.audio_level} disabled={rxOff || st.mute} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
|
<Slider value={st.audio_level} disabled={rxOff || st.mute} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
|
||||||
@@ -495,7 +530,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
<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',
|
||||||
st.amp_operate ? 'bg-orange-600 text-white border-orange-600 shadow-[0_0_14px] shadow-orange-600/50' : 'bg-card text-orange-700 border-orange-400 hover:bg-orange-50')}>
|
st.amp_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')}>
|
||||||
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
|
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@@ -506,13 +541,13 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
selector is disabled until connected (hover it for the error). */}
|
selector is disabled until connected (hover it for the error). */}
|
||||||
{(pg.host || pg.connected) && (
|
{(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-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-rose-500')} />
|
<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>
|
||||||
<select
|
<select
|
||||||
disabled={!pg.connected}
|
disabled={!pg.connected}
|
||||||
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||||||
onChange={(e) => { const v = e.target.value; setPg((s) => ({ ...s, fan_mode: v })); PGXLSetFanMode(v).catch(() => {}); }}
|
onChange={(e) => { const v = e.target.value; setPg((s) => ({ ...s, fan_mode: v })); PGXLSetFanMode(v).catch(() => {}); }}
|
||||||
className="h-8 rounded-md border border-orange-300 bg-card px-2 text-xs font-semibold text-orange-800 outline-none focus:border-orange-500 disabled:opacity-40"
|
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="STANDARD">{t('flxp.fanStandard')}</option>
|
||||||
<option value="CONTEST">{t('flxp.fanContest')}</option>
|
<option value="CONTEST">{t('flxp.fanContest')}</option>
|
||||||
@@ -522,7 +557,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{st.amp_fault && st.amp_fault !== 'NONE' && (
|
{st.amp_fault && st.amp_fault !== 'NONE' && (
|
||||||
<span className="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span>
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Radio, AudioLines, RefreshCw, Mic, Zap, Activity } from 'lucide-react';
|
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna, Filter, Power } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetIcomState, IcomRefresh,
|
GetIcomState, IcomRefresh,
|
||||||
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
||||||
IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
IcomSetANF, IcomSetAPF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
||||||
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT,
|
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT,
|
||||||
IcomSetScope, IcomScopeData, IcomSetScopeMode, GetCATState, SetCATFrequency,
|
IcomSetScope, IcomScopeData, IcomSetScopeMode, IcomSetScopeEdges, GetCATState, SetCATFrequency, SetCATMode,
|
||||||
|
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
|
||||||
|
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
|
||||||
|
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
|
||||||
|
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -17,18 +21,60 @@ type IcomState = {
|
|||||||
s_meter: number; power_meter: number; swr_meter: number;
|
s_meter: number; power_meter: number; swr_meter: number;
|
||||||
rf_power: number; mic_gain: number;
|
rf_power: number; mic_gain: number;
|
||||||
af_gain: number; rf_gain: number;
|
af_gain: number; rf_gain: number;
|
||||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean;
|
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; apf: boolean;
|
||||||
agc?: string; preamp: number; att: number; filter: number;
|
agc?: string; preamp: number; att: number; filter: number;
|
||||||
|
rit_hz: number; rit_on: boolean; xit_on: boolean;
|
||||||
|
antenna: number;
|
||||||
|
pbt_inner: number; pbt_outer: number; manual_notch: boolean; notch_pos: number;
|
||||||
|
squelch: number; comp: boolean; comp_level: number;
|
||||||
|
monitor: boolean; mon_level: number;
|
||||||
|
vox: boolean; vox_gain: number; anti_vox: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ZERO: IcomState = {
|
const ZERO: IcomState = {
|
||||||
available: false, transmitting: false, split: false,
|
available: false, transmitting: false, split: false,
|
||||||
s_meter: 0, power_meter: 0, swr_meter: 0, rf_power: 0, mic_gain: 0,
|
s_meter: 0, power_meter: 0, swr_meter: 0, rf_power: 0, mic_gain: 0,
|
||||||
af_gain: 0, rf_gain: 0,
|
af_gain: 0, rf_gain: 0,
|
||||||
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false,
|
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, apf: false,
|
||||||
preamp: 0, att: 0, filter: 1,
|
preamp: 0, att: 0, filter: 1,
|
||||||
|
rit_hz: 0, rit_on: false, xit_on: false,
|
||||||
|
antenna: 1,
|
||||||
|
pbt_inner: 50, pbt_outer: 50, manual_notch: false, notch_pos: 50,
|
||||||
|
squelch: 0, comp: false, comp_level: 0,
|
||||||
|
monitor: false, mon_level: 0,
|
||||||
|
vox: false, vox_gain: 0, anti_vox: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
||||||
|
// the plain SetFrequency command — no band-stacking codes needed. Hz values.
|
||||||
|
const BANDS: { l: string; hz: number }[] = [
|
||||||
|
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 },
|
||||||
|
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
|
||||||
|
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
|
||||||
|
{ l: '6', hz: 50_150_000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for
|
||||||
|
// SSB by frequency and the rig's data variant for digital modes.
|
||||||
|
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM'];
|
||||||
|
|
||||||
|
// fmtVFO renders a Hz frequency the way an Icom front panel does:
|
||||||
|
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
|
||||||
|
function fmtVFO(hz?: number): string {
|
||||||
|
if (!hz || hz <= 0) return '––.–––.––';
|
||||||
|
const mhz = Math.floor(hz / 1_000_000);
|
||||||
|
const khz = Math.floor((hz % 1_000_000) / 1000);
|
||||||
|
const h2 = Math.floor((hz % 1000) / 10);
|
||||||
|
return `${mhz}.${String(khz).padStart(3, '0')}.${String(h2).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeMatches marks a mode button active, folding the rig's USB/LSB into SSB.
|
||||||
|
function modeMatches(btn: string, cur?: string): boolean {
|
||||||
|
if (!cur) return false;
|
||||||
|
if (btn === 'SSB') return cur === 'SSB' || cur === 'USB' || cur === 'LSB';
|
||||||
|
return btn === cur;
|
||||||
|
}
|
||||||
|
|
||||||
function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
|
function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
|
||||||
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number;
|
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number;
|
||||||
}) {
|
}) {
|
||||||
@@ -61,7 +107,7 @@ function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
|
|||||||
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||||
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
||||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
|
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
|
||||||
'[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')}
|
'[&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')}
|
||||||
style={{ background: `linear-gradient(to right, ${accent} ${v}%, #d8cfb8 ${v}%)`, borderColor: accent }}
|
style={{ background: `linear-gradient(to right, ${accent} ${v}%, #d8cfb8 ${v}%)`, borderColor: accent }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -87,7 +133,7 @@ function Chip({ on, onClick, label }: { on: boolean; onClick: () => void; label:
|
|||||||
return (
|
return (
|
||||||
<button type="button" onClick={onClick}
|
<button type="button" onClick={onClick}
|
||||||
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
on ? 'bg-emerald-600 border-emerald-600 text-white' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
on ? 'bg-success border-success text-success-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -145,6 +191,39 @@ function Meter({ label, value, accent, scale, onClick, title }: { label: string;
|
|||||||
return <div className="flex items-center gap-2">{body}</div>;
|
return <div className="flex items-center gap-2">{body}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShiftRow — a RIT / ΔTX offset control: on/off chip + a wheel-adjustable signed
|
||||||
|
// offset (±10 Hz per notch or per ± button) + a clear (0) button.
|
||||||
|
function ShiftRow({ label, on, hz, accent, onToggle, onDelta, onClear }: {
|
||||||
|
label: string; on: boolean; hz: number; accent: string;
|
||||||
|
onToggle: () => void; onDelta: (d: number) => void; onClear: () => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const cb = useRef(onDelta); cb.current = onDelta;
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => { e.preventDefault(); cb.current(e.deltaY < 0 ? 10 : -10); };
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip on={on} onClick={onToggle} label={label} />
|
||||||
|
<div ref={ref} title="Wheel / ± to shift"
|
||||||
|
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none cursor-ns-resize',
|
||||||
|
on ? 'border-border bg-muted/40' : 'border-border/60 bg-muted/20 opacity-60')}>
|
||||||
|
<button type="button" onClick={() => onDelta(-10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">−</button>
|
||||||
|
<span className="text-sm font-mono font-bold tabular-nums" style={{ color: on ? accent : undefined }}>
|
||||||
|
{hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz)} Hz
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => onDelta(10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">+</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClear}
|
||||||
|
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">0</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the
|
// sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the
|
||||||
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
|
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
|
||||||
// the RST-tx value on click.
|
// the RST-tx value on click.
|
||||||
@@ -157,23 +236,63 @@ function sParts(v: number): { s: number; over: number; label: string } {
|
|||||||
return { s, over: 0, label: `S${s}` };
|
return { s, over: 0, label: `S${s}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wfColor maps a 0-1 amplitude to a classic waterfall colour ramp
|
||||||
|
// (near-black → blue → cyan → green → amber → red).
|
||||||
|
const WF_STOPS: [number, [number, number, number]][] = [
|
||||||
|
[0.0, [8, 12, 28]], [0.22, [26, 58, 138]], [0.42, [0, 150, 190]],
|
||||||
|
[0.62, [46, 200, 120]], [0.80, [240, 210, 70]], [1.0, [244, 63, 60]],
|
||||||
|
];
|
||||||
|
function wfColor(v: number): [number, number, number] {
|
||||||
|
v = Math.max(0, Math.min(1, Math.pow(v, 0.7))); // gamma-lift so the noise floor still has hue
|
||||||
|
for (let i = 1; i < WF_STOPS.length; i++) {
|
||||||
|
if (v <= WF_STOPS[i][0]) {
|
||||||
|
const [a, ca] = WF_STOPS[i - 1], [b, cb] = WF_STOPS[i];
|
||||||
|
const f = (v - a) / (b - a || 1);
|
||||||
|
return [Math.round(ca[0] + (cb[0] - ca[0]) * f), Math.round(ca[1] + (cb[1] - ca[1]) * f), Math.round(ca[2] + (cb[2] - ca[2]) * f)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return WF_STOPS[WF_STOPS.length - 1][1];
|
||||||
|
}
|
||||||
|
|
||||||
// ScopePanadapter — enables the rig's spectrum-scope stream and draws the
|
// ScopePanadapter — enables the rig's spectrum-scope stream and draws the
|
||||||
// reassembled sweep on a canvas. The amplitude array is raw rig scale (~0-160);
|
// reassembled sweep as a modern SDR panadapter: a glowing filled spectrum trace
|
||||||
// we normalise to the tallest recent peak so the trace fills the height.
|
// on top and a scrolling colour waterfall below. Amplitudes are raw rig scale
|
||||||
|
// (~0-160), normalised to the tallest recent peak so the trace fills the height.
|
||||||
function ScopePanadapter() {
|
function ScopePanadapter() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [on, setOn] = useState(false);
|
const [on, setOn] = useState(false);
|
||||||
const [fixed, setFixed] = useState(true);
|
const [fixed, setFixed] = useState(true);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const wfRef = useRef<HTMLCanvasElement>(null); // waterfall
|
||||||
const peakRef = useRef(160); // running amplitude ceiling for auto-scale
|
const peakRef = useRef(160); // running amplitude ceiling for auto-scale
|
||||||
|
const holdRef = useRef<number[]>([]); // per-bin peak-hold line
|
||||||
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
|
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
|
||||||
const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
|
const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
|
||||||
|
const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶)
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
const next = !on;
|
const next = !on;
|
||||||
setOn(next);
|
setOn(next);
|
||||||
IcomSetScope(next).catch(() => {});
|
IcomSetScope(next).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Centre/pan the FIXED scope: set the edges to centre ±50 kHz (a 100 kHz
|
||||||
|
// window). "Centre" uses the live VFO; ◀/▶ shift the window by 50 kHz. This
|
||||||
|
// just writes the rig's fixed edges — simple and independent of the waveform
|
||||||
|
// decode.
|
||||||
|
const SCOPE_HALF = 50_000;
|
||||||
|
const applyEdges = (center: number) => {
|
||||||
|
if (center <= 0) return;
|
||||||
|
centerRef.current = center;
|
||||||
|
setFixed(true);
|
||||||
|
IcomSetScopeEdges(center - SCOPE_HALF, center + SCOPE_HALF).catch(() => {});
|
||||||
|
};
|
||||||
|
const centerOnVfo = async () => {
|
||||||
|
let c = vfoRef.current;
|
||||||
|
if (c <= 0) { try { const cs = await GetCATState(); c = cs?.freq_hz || 0; } catch {} }
|
||||||
|
applyEdges(c);
|
||||||
|
};
|
||||||
|
const pan = (dir: number) => applyEdges((centerRef.current || vfoRef.current) + dir * SCOPE_HALF);
|
||||||
const setMode = (nextFixed: boolean) => {
|
const setMode = (nextFixed: boolean) => {
|
||||||
setFixed(nextFixed);
|
setFixed(nextFixed);
|
||||||
IcomSetScopeMode(nextFixed).catch(() => {});
|
IcomSetScopeMode(nextFixed).catch(() => {});
|
||||||
@@ -198,7 +317,7 @@ function ScopePanadapter() {
|
|||||||
vfo = cs?.split && cs.freq_rx_hz ? cs.freq_rx_hz : (cs?.freq_hz || 0);
|
vfo = cs?.split && cs.freq_rx_hz ? cs.freq_rx_hz : (cs?.freq_hz || 0);
|
||||||
} catch {}
|
} catch {}
|
||||||
if (vfo > 0) vfoRef.current = vfo;
|
if (vfo > 0) vfoRef.current = vfo;
|
||||||
draw(sw.amp, sw.low_hz, sw.high_hz, vfo);
|
draw(sw.amp, sw.low_hz, sw.high_hz, vfoRef.current, sw.fixed);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
if (alive) raf = window.setTimeout(() => { raf = requestAnimationFrame(tick); }, 40) as unknown as number;
|
if (alive) raf = window.setTimeout(() => { raf = requestAnimationFrame(tick); }, 40) as unknown as number;
|
||||||
@@ -236,7 +355,7 @@ function ScopePanadapter() {
|
|||||||
return () => el.removeEventListener('wheel', onWheel);
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
}, [on]);
|
}, [on]);
|
||||||
|
|
||||||
const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number) => {
|
const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number, fixedMode: boolean) => {
|
||||||
const cv = canvasRef.current;
|
const cv = canvasRef.current;
|
||||||
if (!cv) return;
|
if (!cv) return;
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
@@ -245,66 +364,152 @@ function ScopePanadapter() {
|
|||||||
const ctx = cv.getContext('2d');
|
const ctx = cv.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
ctx.clearRect(0, 0, w, h);
|
|
||||||
|
|
||||||
// Auto-scale: track the peak, decaying slowly so the floor doesn't jump.
|
// Auto-scale: track the peak, decaying slowly so the floor doesn't jump.
|
||||||
const peak = Math.max(...amp);
|
const peak = Math.max(...amp);
|
||||||
peakRef.current = Math.max(peak, peakRef.current * 0.95, 40);
|
peakRef.current = Math.max(peak, peakRef.current * 0.95, 40);
|
||||||
const scale = peakRef.current;
|
const scale = peakRef.current;
|
||||||
|
const n = amp.length;
|
||||||
|
|
||||||
|
// Background — deep navy vertical gradient.
|
||||||
|
const bg = ctx.createLinearGradient(0, 0, 0, h);
|
||||||
|
bg.addColorStop(0, '#0b1220'); bg.addColorStop(1, '#05070e');
|
||||||
|
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
// Grid.
|
// Grid.
|
||||||
ctx.strokeStyle = 'rgba(120,120,120,0.15)';
|
ctx.strokeStyle = 'rgba(120,150,200,0.08)';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
for (let i = 1; i < 4; i++) { const y = (h * i) / 4; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
for (let i = 1; i < 4; i++) { const y = (h * i) / 4; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
||||||
for (let i = 1; i < 8; i++) { const x = (w * i) / 8; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
|
for (let i = 1; i < 8; i++) { const x = (w * i) / 8; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
|
||||||
|
|
||||||
// Spectrum trace, filled under the line.
|
const xOf = (i: number) => (i / (n - 1)) * w;
|
||||||
const n = amp.length;
|
const yOf = (v: number) => h - Math.min(1, v / scale) * h;
|
||||||
|
|
||||||
|
// Peak-hold line (slow decay) — a faint ghost of recent maxima.
|
||||||
|
const hold = holdRef.current;
|
||||||
|
if (hold.length !== n) hold.length = n, hold.fill(0);
|
||||||
|
for (let i = 0; i < n; i++) hold[i] = Math.max(amp[i], hold[i] * 0.92);
|
||||||
|
|
||||||
|
// Filled spectrum area.
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(0, h);
|
ctx.moveTo(0, h);
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) ctx.lineTo(xOf(i), yOf(amp[i]));
|
||||||
const x = (i / (n - 1)) * w;
|
ctx.lineTo(w, h); ctx.closePath();
|
||||||
const y = h - Math.min(1, amp[i] / scale) * h;
|
|
||||||
ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
ctx.lineTo(w, h);
|
|
||||||
ctx.closePath();
|
|
||||||
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
||||||
grad.addColorStop(0, 'rgba(56,189,248,0.55)');
|
grad.addColorStop(0, 'rgba(56,189,248,0.40)');
|
||||||
grad.addColorStop(1, 'rgba(56,189,248,0.05)');
|
grad.addColorStop(1, 'rgba(56,189,248,0.02)');
|
||||||
ctx.fillStyle = grad;
|
ctx.fillStyle = grad; ctx.fill();
|
||||||
ctx.fill();
|
|
||||||
ctx.strokeStyle = '#38bdf8';
|
|
||||||
ctx.lineWidth = 1.25;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// VFO marker: a vertical line at the current frequency within the span.
|
// Peak-hold trace (thin, faint).
|
||||||
if (vfoHz > 0 && lowHz > 0 && highHz > lowHz && vfoHz >= lowHz && vfoHz <= highHz) {
|
ctx.beginPath();
|
||||||
const x = ((vfoHz - lowHz) / (highHz - lowHz)) * w;
|
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(hold[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
|
||||||
ctx.strokeStyle = 'rgba(239,68,68,0.95)';
|
ctx.strokeStyle = 'rgba(148,197,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
|
// Live spectrum trace with a soft glow.
|
||||||
|
ctx.save();
|
||||||
|
ctx.shadowColor = 'rgba(56,189,248,0.7)'; ctx.shadowBlur = 6;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(amp[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
|
||||||
|
ctx.strokeStyle = '#7dd3fc'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// VFO marker: you should ALWAYS see where you are. Exact position when the VFO
|
||||||
|
// is inside the span; the centre in CTR mode; clamped to the nearest edge with
|
||||||
|
// a sideways arrow in FIX mode when the fixed scope doesn't cover the VFO (so
|
||||||
|
// you can tell which way to tune to get it back on-screen).
|
||||||
|
const haveVfo = vfoHz > 0 && lowHz > 0 && highHz > lowHz;
|
||||||
|
const inSpan = haveVfo && vfoHz >= lowHz && vfoHz <= highHz;
|
||||||
|
let markerX = -1;
|
||||||
|
let offEdge = 0; // -1 = VFO off the left edge, +1 = off the right
|
||||||
|
if (inSpan) markerX = ((vfoHz - lowHz) / (highHz - lowHz)) * w;
|
||||||
|
else if (!fixedMode) markerX = w / 2;
|
||||||
|
else if (haveVfo) { offEdge = vfoHz < lowHz ? -1 : 1; markerX = offEdge < 0 ? 1 : w - 1; }
|
||||||
|
if (markerX >= 0) {
|
||||||
|
const x = markerX;
|
||||||
|
ctx.fillStyle = 'rgba(244,63,94,0.10)'; ctx.fillRect(x - 5, 0, 10, h);
|
||||||
|
ctx.strokeStyle = 'rgba(244,63,94,0.9)'; ctx.lineWidth = 1.25;
|
||||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
|
||||||
|
ctx.fillStyle = 'rgba(244,63,94,0.95)';
|
||||||
|
if (offEdge === 0) {
|
||||||
|
ctx.beginPath(); ctx.moveTo(x - 4, 0); ctx.lineTo(x + 4, 0); ctx.lineTo(x, 6); ctx.closePath(); ctx.fill();
|
||||||
|
} else {
|
||||||
|
const yh = 8; ctx.beginPath(); ctx.moveTo(x, yh - 5); ctx.lineTo(x + offEdge * 7, yh); ctx.lineTo(x, yh + 5); ctx.closePath(); ctx.fill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frequency scale (edges + centre), only when the rig reported the span.
|
// Frequency scale. In fixed mode the rig reports usable edge frequencies, so
|
||||||
if (lowHz > 0 && highHz > lowHz) {
|
// we label low/centre/high from them. In centre mode the header frame's edge
|
||||||
|
// pair isn't a usable low..high range, but the scope is centred on the VFO —
|
||||||
|
// so we always label the centre with the live VFO frequency (which we fetch
|
||||||
|
// each sweep), and only add edge labels when the reported edges genuinely
|
||||||
|
// bracket the VFO. That guarantees you always see your frequency in CTR.
|
||||||
const mhz = (hz: number) => (hz / 1e6).toFixed(3);
|
const mhz = (hz: number) => (hz / 1e6).toFixed(3);
|
||||||
ctx.fillStyle = 'rgba(226,232,240,0.75)';
|
|
||||||
ctx.font = '10px ui-monospace, monospace';
|
ctx.font = '10px ui-monospace, monospace';
|
||||||
ctx.textBaseline = 'bottom';
|
ctx.textBaseline = 'bottom';
|
||||||
ctx.textAlign = 'left'; ctx.fillText(mhz(lowHz), 3, h - 2);
|
ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 3;
|
||||||
ctx.textAlign = 'center'; ctx.fillText(mhz((lowHz + highHz) / 2), w / 2, h - 2);
|
ctx.fillStyle = 'rgba(226,232,240,0.85)';
|
||||||
ctx.textAlign = 'right'; ctx.fillText(mhz(highHz), w - 3, h - 2);
|
const label = (txt: string, x: number, align: CanvasTextAlign) => { ctx.textAlign = align; ctx.fillText(txt, x, h - 3); };
|
||||||
|
const validEdges = lowHz > 0 && highHz > lowHz;
|
||||||
|
if (fixedMode) {
|
||||||
|
if (validEdges) {
|
||||||
|
label(mhz(lowHz), 4, 'left');
|
||||||
|
label(mhz((lowHz + highHz) / 2), w / 2, 'center');
|
||||||
|
label(mhz(highHz), w - 4, 'right');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (validEdges && vfoHz >= lowHz && vfoHz <= highHz) {
|
||||||
|
label(mhz(lowHz), 4, 'left');
|
||||||
|
label(mhz(highHz), w - 4, 'right');
|
||||||
|
}
|
||||||
|
if (vfoHz > 0) label(mhz(vfoHz), w / 2, 'center');
|
||||||
|
}
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
drawWaterfall(amp, scale);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// drawWaterfall scrolls the history down one row and paints the newest sweep
|
||||||
|
// as a colour-mapped line at the top.
|
||||||
|
const drawWaterfall = (amp: number[], scale: number) => {
|
||||||
|
const cv = wfRef.current;
|
||||||
|
if (!cv) return;
|
||||||
|
const w = Math.max(1, cv.clientWidth), h = Math.max(1, cv.clientHeight);
|
||||||
|
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
// Scroll everything down by one pixel row.
|
||||||
|
ctx.drawImage(cv, 0, 0, w, h - 1, 0, 1, w, h - 1);
|
||||||
|
// Paint the new top row.
|
||||||
|
const row = ctx.createImageData(w, 1);
|
||||||
|
const n = amp.length;
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const i = Math.min(n - 1, Math.round((x / (w - 1)) * (n - 1)));
|
||||||
|
const [r, g, b] = wfColor(amp[i] / scale);
|
||||||
|
const o = x * 4;
|
||||||
|
row.data[o] = r; row.data[o + 1] = g; row.data[o + 2] = b; row.data[o + 3] = 255;
|
||||||
|
}
|
||||||
|
ctx.putImageData(row, 0, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collapsible card: when the scope is off, only the header band shows (the
|
||||||
|
// canvas is hidden entirely) so it doesn't waste vertical space. The CTR/FIX
|
||||||
|
// and ON/OFF controls live in the header itself.
|
||||||
return (
|
return (
|
||||||
<Card icon={Activity} title={t('icmp.spectrum')} accent="#38bdf8">
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
<span className="text-[11px] text-muted-foreground truncate">
|
<Activity className="size-4" style={{ color: '#38bdf8' }} />
|
||||||
{on ? (fixed ? t('icmp.scopeFixed') : t('icmp.scopeCenter')) : t('icmp.scopeOff')}
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('icmp.spectrum')}</span>
|
||||||
</span>
|
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
{on && (
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
<button type="button" onClick={() => pan(-1)} title={t('icmp.scopePanDown')}
|
||||||
|
className="px-2 py-1 text-xs font-bold bg-card text-muted-foreground hover:bg-muted border-r border-border">◀</button>
|
||||||
|
<button type="button" onClick={centerOnVfo} title={t('icmp.scopeCenterVfo')}
|
||||||
|
className="px-2 py-1 text-[11px] font-bold bg-card text-muted-foreground hover:bg-muted border-r border-border">⊙</button>
|
||||||
|
<button type="button" onClick={() => pan(1)} title={t('icmp.scopePanUp')}
|
||||||
|
className="px-2 py-1 text-xs font-bold bg-card text-muted-foreground hover:bg-muted">▶</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{on && (
|
{on && (
|
||||||
<Segmented value={fixed ? 'FIX' : 'CTR'} options={[{ v: 'CTR', l: 'CTR' }, { v: 'FIX', l: 'FIX' }]}
|
<Segmented value={fixed ? 'FIX' : 'CTR'} options={[{ v: 'CTR', l: 'CTR' }, { v: 'FIX', l: 'FIX' }]}
|
||||||
onChange={(v) => setMode(v === 'FIX')} />
|
onChange={(v) => setMode(v === 'FIX')} />
|
||||||
@@ -312,11 +517,16 @@ function ScopePanadapter() {
|
|||||||
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
|
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-border bg-black/70 overflow-hidden">
|
{on && (
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="rounded-xl overflow-hidden ring-1 ring-info/20 shadow-lg shadow-sky-500/5 bg-[#05070e]">
|
||||||
<canvas ref={canvasRef} onDoubleClick={onDblClick}
|
<canvas ref={canvasRef} onDoubleClick={onDblClick}
|
||||||
className="w-full block cursor-crosshair" style={{ height: 150 }} />
|
className="w-full block cursor-crosshair" style={{ height: 140 }} />
|
||||||
|
<canvas ref={wfRef} className="w-full block" style={{ height: 96 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,16 +537,21 @@ function ScopePanadapter() {
|
|||||||
export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void } = {}) {
|
export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void } = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [st, setSt] = useState<IcomState>(ZERO);
|
const [st, setSt] = useState<IcomState>(ZERO);
|
||||||
const [busy, setBusy] = useState(false);
|
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
||||||
const [tuning, setTuning] = useState(false);
|
const [tuning, setTuning] = useState(false);
|
||||||
const txRef = useRef(false);
|
const txRef = useRef(false);
|
||||||
|
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
|
||||||
|
|
||||||
const load = () => GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
|
const load = () => {
|
||||||
|
GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
|
||||||
|
GetCATState().then((c) => setCat(c ?? null)).catch(() => {});
|
||||||
|
};
|
||||||
|
const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); };
|
||||||
|
// Initial one-shot read of the rig's DSP snapshot on mount (the 500ms poll only
|
||||||
|
// re-reads the cache; the backend also loads DSP on the first responsive read).
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
setBusy(true);
|
|
||||||
try { await IcomRefresh(); } catch {}
|
try { await IcomRefresh(); } catch {}
|
||||||
await load();
|
await load();
|
||||||
setBusy(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -364,6 +579,27 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
window.setTimeout(() => setTuning(false), 4000);
|
window.setTimeout(() => setTuning(false), 4000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// RIT/ΔTX offset (signed Hz, clamped ±9999). Optimistic like the DSP controls.
|
||||||
|
const setRit = (hz: number) => {
|
||||||
|
const v = Math.max(-9999, Math.min(9999, hz));
|
||||||
|
set({ rit_hz: v }, () => IcomSetRIT(v));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ctrl+Left/Right shifts the RIT by ±10 Hz while RIT is active — a keyboard
|
||||||
|
// clarifier for zero-beating a caller without touching the mouse.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (!e.ctrlKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
|
||||||
|
const s = stRef.current;
|
||||||
|
if (!s.available || !s.rit_on) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setRit(s.rit_hz + (e.key === 'ArrowRight' ? 10 : -10));
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!st.available) {
|
if (!st.available) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
|
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
|
||||||
@@ -373,6 +609,17 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tx = st.transmitting;
|
const tx = st.transmitting;
|
||||||
|
// VFO readout. In split the active/listening VFO is RX (freq_rx_hz) and the
|
||||||
|
// other is TX (freq_hz); otherwise there's a single VFO (freq_hz).
|
||||||
|
const split = !!cat?.split;
|
||||||
|
const mainHz: number = split ? (cat?.freq_rx_hz || 0) : (cat?.freq_hz || 0);
|
||||||
|
const subHz: number = split ? (cat?.freq_hz || 0) : 0;
|
||||||
|
const curMode: string = cat?.mode || st.mode || '';
|
||||||
|
// Mode-dependent controls: VOX / speech-comp / mic are voice-only (hidden on
|
||||||
|
// CW and data); APF (audio peak filter) is CW-only. Fold USB/LSB into phone.
|
||||||
|
const um = curMode.toUpperCase();
|
||||||
|
const isCW = um === 'CW' || um === 'CWR';
|
||||||
|
const isPhone = um === 'SSB' || um === 'USB' || um === 'LSB' || um === 'AM' || um === 'FM';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full min-h-0 overflow-auto bg-background">
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||||
@@ -381,36 +628,110 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
<div className="flex items-center justify-between rounded-xl border border-border bg-card px-3 py-2 shadow-sm">
|
<div className="flex items-center justify-between rounded-xl border border-border bg-card px-3 py-2 shadow-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={cn('inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-bold uppercase tracking-wider',
|
<span className={cn('inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-bold uppercase tracking-wider',
|
||||||
tx ? 'bg-red-600 text-white' : 'bg-emerald-600 text-white')}>
|
tx ? 'bg-destructive text-destructive-foreground' : 'bg-success text-success-foreground')}>
|
||||||
<span className={cn('size-2 rounded-full', tx ? 'bg-white animate-pulse' : 'bg-white/90')} />
|
<span className={cn('size-2 rounded-full', tx ? 'bg-card animate-pulse' : 'bg-card/90')} />
|
||||||
{tx ? 'TX' : 'RX'}
|
{tx ? 'TX' : 'RX'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-bold">{st.model || 'Icom'}</span>
|
<span className="text-sm font-bold">{st.model || 'Icom'}</span>
|
||||||
{st.mode ? <span className="text-xs font-mono text-muted-foreground">{st.mode}</span> : null}
|
{st.mode ? <span className="text-xs font-mono text-muted-foreground">{st.mode}</span> : null}
|
||||||
{st.split ? <span className="rounded-md bg-amber-500/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-amber-600">Split</span> : null}
|
{st.split ? <span className="rounded-md bg-warning/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">Split</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={refresh} disabled={busy}
|
<div className="flex items-center gap-1.5">
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-xs hover:bg-muted disabled:opacity-40">
|
{/* Radio power ON / OFF. Manual by design — the app never wakes the rig
|
||||||
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('icmp.refresh')}
|
on connect; ON sends the wake preamble then the rig boots ~15 s. */}
|
||||||
|
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
|
||||||
|
<Power className="size-3.5" /> ON
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onClick={() => IcomSetPower(false).catch(() => {})} title={t('icmp.powerOffHint')}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-destructive/60 bg-destructive/10 px-2 py-1 text-xs font-bold text-destructive hover:bg-destructive/20">
|
||||||
|
<Power className="size-3.5" /> OFF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* VFO readout — the RS-BA1-style twin display: MAIN (active) + SUB, the big
|
||||||
|
tabular frequency, mode badge, band, and the RIT/ΔTX offset. */}
|
||||||
|
<div className="rounded-xl border border-border bg-muted/25 shadow-inner overflow-hidden">
|
||||||
|
<div className="grid grid-cols-2 divide-x divide-border/60">
|
||||||
|
{/* MAIN VFO */}
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className={cn('text-[10px] font-bold uppercase tracking-widest', tx ? 'text-destructive' : 'text-success')}>{tx ? 'Main · TX' : 'Main'}</span>
|
||||||
|
{curMode ? <span className="rounded px-1.5 py-0.5 text-[10px] font-bold bg-primary/15 text-primary">{curMode}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono font-bold tabular-nums leading-none text-foreground" style={{ fontSize: 'clamp(1.5rem, 4.5vw, 2.25rem)' }}>{fmtVFO(mainHz)}</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2 text-[11px] font-mono text-muted-foreground">
|
||||||
|
<span>{cat?.band || (mainHz ? '' : '—')}</span>
|
||||||
|
{st.rit_on ? <span className="text-primary">RIT {st.rit_hz > 0 ? '+' : st.rit_hz < 0 ? '−' : ''}{Math.abs(st.rit_hz)}</span> : null}
|
||||||
|
{st.xit_on ? <span className="text-warning">ΔTX</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* SUB VFO (populated in split; dimmed otherwise) */}
|
||||||
|
<div className={cn('px-4 py-3', !split && 'opacity-40')}>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Sub</span>
|
||||||
|
{split ? <span className="rounded px-1.5 py-0.5 text-[10px] font-bold bg-warning/15 text-warning">SPLIT</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono font-bold tabular-nums leading-none text-muted-foreground" style={{ fontSize: 'clamp(1.5rem, 4.5vw, 2.25rem)' }}>{fmtVFO(subHz)}</div>
|
||||||
|
<div className="mt-1.5 text-[11px] font-mono text-muted-foreground">{split ? 'TX' : ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
|
||||||
|
<div className="grid grid-cols-6 border-t border-border/60 divide-x divide-border/60">
|
||||||
|
{MODES.map((m) => {
|
||||||
|
const on = modeMatches(m, curMode);
|
||||||
|
return (
|
||||||
|
<button key={m} type="button" onClick={() => setMode(m)}
|
||||||
|
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
|
||||||
|
on ? 'bg-primary text-primary-foreground' : 'bg-card/40 text-muted-foreground hover:bg-muted')}>
|
||||||
|
{m}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live meters — always visible: S (RX, click → RST), Po in watts, SWR. */}
|
||||||
|
<div className="rounded-xl border border-border bg-card px-3 py-2.5 shadow-sm grid grid-cols-1 sm:grid-cols-3 gap-x-5 gap-y-2">
|
||||||
|
{(() => { const sp = sParts(st.s_meter); return (
|
||||||
|
<Meter label="S" value={st.s_meter} accent="#22c55e" scale={sp.label}
|
||||||
|
title={onReportRST ? t('rst.clickToFill') : undefined}
|
||||||
|
onClick={onReportRST ? () => onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
|
||||||
|
); })()}
|
||||||
|
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter} W`} />
|
||||||
|
<Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spectrum panadapter (full width). */}
|
{/* Spectrum panadapter (full width). */}
|
||||||
<ScopePanadapter />
|
<ScopePanadapter />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
{/* Live meters. */}
|
{/* Band buttons + antenna selection. */}
|
||||||
<Card icon={Zap} title={t('icmp.meters')} accent="#f59e0b">
|
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
||||||
{tx ? (
|
<div className="grid grid-cols-5 gap-1.5">
|
||||||
<>
|
{BANDS.map((b) => (
|
||||||
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter}%`} />
|
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
||||||
<Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
|
className="px-1 py-1.5 rounded-md text-[11px] font-bold border border-border bg-card text-foreground hover:bg-muted transition-colors">
|
||||||
</>
|
{b.l}
|
||||||
) : (() => { const sp = sParts(st.s_meter); return (
|
</button>
|
||||||
<Meter label="S" value={st.s_meter} accent="#22c55e" scale={sp.label}
|
))}
|
||||||
title={onReportRST ? t('rst.clickToFill') : undefined}
|
</div>
|
||||||
onClick={onReportRST ? () => onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
|
<Row label={t('icmp.antenna')}>
|
||||||
); })()}
|
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
||||||
|
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
|
||||||
|
<Card icon={SlidersHorizontal} title={t('icmp.clarifiers')} accent="#8b5cf6">
|
||||||
|
<ShiftRow label="RIT" accent="#8b5cf6" on={st.rit_on} hz={st.rit_hz}
|
||||||
|
onToggle={() => set({ rit_on: !st.rit_on }, () => IcomSetRITOn(!st.rit_on))}
|
||||||
|
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
|
||||||
|
<ShiftRow label="ΔTX" accent="#f59e0b" on={st.xit_on} hz={st.rit_hz}
|
||||||
|
onToggle={() => set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))}
|
||||||
|
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('icmp.ritHint')}</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Transmit controls. */}
|
{/* Transmit controls. */}
|
||||||
@@ -419,23 +740,46 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
<Slider value={st.rf_power} accent="#ef4444" onChange={(v) => set({ rf_power: v }, () => IcomSetRFPower(v))} />
|
<Slider value={st.rf_power} accent="#ef4444" onChange={(v) => set({ rf_power: v }, () => IcomSetRFPower(v))} />
|
||||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_power}</span>
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_power}</span>
|
||||||
</Row>
|
</Row>
|
||||||
|
{isPhone && (
|
||||||
<Row label={t('icmp.mic')}>
|
<Row label={t('icmp.mic')}>
|
||||||
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
|
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
|
||||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
|
||||||
</Row>
|
</Row>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
<button type="button" onClick={toggleMox}
|
<button type="button" onClick={toggleMox}
|
||||||
className={cn('flex-1 px-3 py-1.5 rounded-md text-xs font-bold border transition-colors',
|
className={cn('flex-1 px-3 py-1.5 rounded-md text-xs font-bold border transition-colors',
|
||||||
tx ? 'bg-red-600 border-red-600 text-white' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
tx ? 'bg-destructive border-destructive text-destructive-foreground' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
||||||
{tx ? 'TX ON' : 'MOX'}
|
{tx ? 'TX ON' : 'MOX'}
|
||||||
</button>
|
</button>
|
||||||
<Chip label="SPLIT" on={st.split} onClick={() => set({ split: !st.split }, () => IcomSetSplit(!st.split))} />
|
<Chip label="SPLIT" on={st.split} onClick={() => set({ split: !st.split }, () => IcomSetSplit(!st.split))} />
|
||||||
<button type="button" onClick={tune} disabled={tuning}
|
<button type="button" onClick={tune} disabled={tuning}
|
||||||
className={cn('w-14 shrink-0 px-2 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
className={cn('w-14 shrink-0 px-2 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
tuning ? 'bg-amber-500 border-amber-500 text-white animate-pulse' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
tuning ? 'bg-warning border-warning text-warning-foreground animate-pulse' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
||||||
TUNE
|
TUNE
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Monitor (all modes) + speech processor / VOX (voice modes only —
|
||||||
|
they don't exist on CW or data). */}
|
||||||
|
<div className="pt-2 mt-1 border-t border-border/60 space-y-3">
|
||||||
|
<LevelRow label="MON" on={st.monitor} value={st.mon_level}
|
||||||
|
onToggle={() => set({ monitor: !st.monitor }, () => IcomSetMonitor(!st.monitor))}
|
||||||
|
onLevel={(v) => set({ mon_level: v }, () => IcomSetMonLevel(v))} />
|
||||||
|
{isPhone && (
|
||||||
|
<>
|
||||||
|
<LevelRow label="COMP" on={st.comp} value={st.comp_level}
|
||||||
|
onToggle={() => set({ comp: !st.comp }, () => IcomSetComp(!st.comp))}
|
||||||
|
onLevel={(v) => set({ comp_level: v }, () => IcomSetCompLevel(v))} />
|
||||||
|
<LevelRow label="VOX" on={st.vox} value={st.vox_gain}
|
||||||
|
onToggle={() => set({ vox: !st.vox }, () => IcomSetVOX(!st.vox))}
|
||||||
|
onLevel={(v) => set({ vox_gain: v }, () => IcomSetVOXGain(v))} />
|
||||||
|
<Row label="Anti-VOX">
|
||||||
|
<Slider value={st.anti_vox} disabled={!st.vox} onChange={(v) => set({ anti_vox: v }, () => IcomSetAntiVOX(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.anti_vox}</span>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card icon={Radio} title={t('icmp.receive')} accent="#2563eb">
|
<Card icon={Radio} title={t('icmp.receive')} accent="#2563eb">
|
||||||
@@ -447,6 +791,10 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
<Slider value={st.rf_gain} onChange={(v) => set({ rf_gain: v }, () => IcomSetRFGain(v))} />
|
<Slider value={st.rf_gain} onChange={(v) => set({ rf_gain: v }, () => IcomSetRFGain(v))} />
|
||||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_gain}</span>
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_gain}</span>
|
||||||
</Row>
|
</Row>
|
||||||
|
<Row label={t('icmp.squelch')}>
|
||||||
|
<Slider value={st.squelch} onChange={(v) => set({ squelch: v }, () => IcomSetSquelch(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.squelch}</span>
|
||||||
|
</Row>
|
||||||
<Row label="AGC">
|
<Row label="AGC">
|
||||||
<Segmented value={st.agc || ''} options={[{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }]}
|
<Segmented value={st.agc || ''} options={[{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }]}
|
||||||
onChange={(v) => set({ agc: v }, () => IcomSetAGC(v))} />
|
onChange={(v) => set({ agc: v }, () => IcomSetAGC(v))} />
|
||||||
@@ -465,6 +813,31 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Twin PBT + manual notch. Sliders are 0-100 with 50 = centre. */}
|
||||||
|
<Card icon={Filter} title={t('icmp.passband')} accent="#7c3aed">
|
||||||
|
<Row label="PBT-IN">
|
||||||
|
<Slider value={st.pbt_inner} accent="#7c3aed" onChange={(v) => set({ pbt_inner: v }, () => IcomSetPBTInner(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.pbt_inner - 50 > 0 ? '+' : ''}{st.pbt_inner - 50}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="PBT-OUT">
|
||||||
|
<Slider value={st.pbt_outer} accent="#7c3aed" onChange={(v) => set({ pbt_outer: v }, () => IcomSetPBTOuter(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.pbt_outer - 50 > 0 ? '+' : ''}{st.pbt_outer - 50}</span>
|
||||||
|
</Row>
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => { set({ pbt_inner: 50 }, () => IcomSetPBTInner(50)); set({ pbt_outer: 50 }, () => IcomSetPBTOuter(50)); }}
|
||||||
|
className="w-full py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||||||
|
{t('icmp.pbtCenter')}
|
||||||
|
</button>
|
||||||
|
<div className="pt-1 border-t border-border/60 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip label="MN" on={st.manual_notch} onClick={() => set({ manual_notch: !st.manual_notch }, () => IcomSetManualNotch(!st.manual_notch))} />
|
||||||
|
<Slider value={st.notch_pos} disabled={!st.manual_notch} accent="#7c3aed" onChange={(v) => set({ notch_pos: v }, () => IcomSetNotchPos(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.notch_pos}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('icmp.manualNotch')}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card icon={AudioLines} title={t('icmp.noiseNotch')} accent="#16a34a">
|
<Card icon={AudioLines} title={t('icmp.noiseNotch')} accent="#16a34a">
|
||||||
<LevelRow label="NB" on={st.nb} value={st.nb_level}
|
<LevelRow label="NB" on={st.nb} value={st.nb_level}
|
||||||
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
|
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
|
||||||
@@ -476,6 +849,13 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
<Chip label="ANF" on={st.anf} onClick={() => set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} />
|
<Chip label="ANF" on={st.anf} onClick={() => set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} />
|
||||||
<span className="text-xs text-muted-foreground">{t('icmp.autoNotch')}</span>
|
<span className="text-xs text-muted-foreground">{t('icmp.autoNotch')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* APF (audio peak filter) — CW only: peaks the CW tone. */}
|
||||||
|
{isCW && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip label="APF" on={st.apf} onClick={() => set({ apf: !st.apf }, () => IcomSetAPF(!st.apf))} />
|
||||||
|
<span className="text-xs text-muted-foreground">{t('icmp.apf')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
AllCommunityModule, ModuleRegistry,
|
||||||
type ColDef,
|
type ColDef,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus } from 'lucide-react';
|
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
@@ -19,32 +20,13 @@ import {
|
|||||||
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
|
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
|
||||||
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
|
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
|
||||||
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
|
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
|
||||||
|
WorkedBefore,
|
||||||
} from '@/../wailsjs/go/main/App';
|
} from '@/../wailsjs/go/main/App';
|
||||||
import { netctl } from '@/../wailsjs/go/models';
|
import { netctl } from '@/../wailsjs/go/models';
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
const hamlogTheme = themeQuartz.withParams({
|
const hamlogTheme = hamlogGridTheme;
|
||||||
fontFamily: 'inherit',
|
|
||||||
fontSize: 12.5,
|
|
||||||
backgroundColor: '#faf6ea',
|
|
||||||
foregroundColor: '#2a2419',
|
|
||||||
headerBackgroundColor: '#e8dfc9',
|
|
||||||
headerTextColor: '#5a4f3a',
|
|
||||||
headerFontWeight: 600,
|
|
||||||
oddRowBackgroundColor: '#f5efe0',
|
|
||||||
rowHoverColor: '#ecdcb4',
|
|
||||||
selectedRowBackgroundColor: '#f0d9a8',
|
|
||||||
borderColor: '#c8b994',
|
|
||||||
rowBorder: { color: '#d8c9a8', width: 1 },
|
|
||||||
columnBorder: { color: '#d8c9a8', width: 1 },
|
|
||||||
cellHorizontalPadding: 10,
|
|
||||||
rowHeight: 30,
|
|
||||||
headerHeight: 32,
|
|
||||||
spacing: 4,
|
|
||||||
accentColor: '#b8410c',
|
|
||||||
iconSize: 12,
|
|
||||||
});
|
|
||||||
|
|
||||||
type Net = netctl.Net;
|
type Net = netctl.Net;
|
||||||
type Station = netctl.Station;
|
type Station = netctl.Station;
|
||||||
@@ -86,6 +68,36 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
const activeGrid = useRef<any>(null);
|
const activeGrid = useRef<any>(null);
|
||||||
const rosterGrid = useRef<any>(null);
|
const rosterGrid = useRef<any>(null);
|
||||||
|
|
||||||
|
// Worked-before for the clicked station (see if/when we contacted it before).
|
||||||
|
const [wbCall, setWbCall] = useState('');
|
||||||
|
const [wb, setWb] = useState<any>(null);
|
||||||
|
const [wbBusy, setWbBusy] = useState(false);
|
||||||
|
const wbReq = useRef(0);
|
||||||
|
// Resizable height (drag the handle on top). Persisted.
|
||||||
|
const [wbHeight, setWbHeight] = useState(() => {
|
||||||
|
const v = parseInt(localStorage.getItem('opslog.netWbHeight') || '', 10);
|
||||||
|
return v >= 80 && v <= 600 ? v : 176;
|
||||||
|
});
|
||||||
|
useEffect(() => { localStorage.setItem('opslog.netWbHeight', String(wbHeight)); }, [wbHeight]);
|
||||||
|
const startWbResize = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const startY = e.clientY, startH = wbHeight;
|
||||||
|
const onMove = (ev: MouseEvent) => setWbHeight(Math.max(80, Math.min(600, startH - (ev.clientY - startY))));
|
||||||
|
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
const showWorkedBefore = useCallback(async (call: string, dxcc = 0) => {
|
||||||
|
const c = (call ?? '').trim().toUpperCase();
|
||||||
|
setWbCall(c);
|
||||||
|
if (!c) { setWb(null); return; }
|
||||||
|
const req = ++wbReq.current;
|
||||||
|
setWbBusy(true);
|
||||||
|
try { const r = await WorkedBefore(c, dxcc); if (wbReq.current === req) setWb(r); }
|
||||||
|
catch { if (wbReq.current === req) setWb(null); }
|
||||||
|
finally { if (wbReq.current === req) setWbBusy(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isOpen = openId !== '' && openId === selId;
|
const isOpen = openId !== '' && openId === selId;
|
||||||
|
|
||||||
const refreshNets = useCallback(async () => {
|
const refreshNets = useCallback(async () => {
|
||||||
@@ -192,6 +204,9 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
...c, callsign: (r.callsign || call).toUpperCase(),
|
...c, callsign: (r.callsign || call).toUpperCase(),
|
||||||
name: r.name || c.name, qth: r.qth || c.qth, country: r.country || c.country,
|
name: r.name || c.name, qth: r.qth || c.qth, country: r.country || c.country,
|
||||||
dxcc: r.dxcc || c.dxcc, itu: r.itu || c.itu, cq: r.cq || c.cq,
|
dxcc: r.dxcc || c.dxcc, itu: r.itu || c.itu, cq: r.cq || c.cq,
|
||||||
|
grid: r.grid || c.grid, address: r.address || c.address, state: r.state || c.state,
|
||||||
|
cnty: r.cnty || c.cnty, cont: r.cont || c.cont, lat: r.lat || c.lat, lon: r.lon || c.lon,
|
||||||
|
email: r.email || c.email,
|
||||||
}));
|
}));
|
||||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
finally { setLooking(false); }
|
finally { setLooking(false); }
|
||||||
@@ -257,12 +272,12 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
<Radio className="size-3.5" /> {isOpen ? t('ncp.closeNet') : t('ncp.openNet')}
|
<Radio className="size-3.5" /> {isOpen ? t('ncp.closeNet') : t('ncp.openNet')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-8" disabled={!selId || isOpen} onClick={renameNet}>{t('ncp.rename')}</Button>
|
<Button variant="ghost" size="sm" className="h-8" disabled={!selId || isOpen} onClick={renameNet}>{t('ncp.rename')}</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-8 text-rose-700" disabled={!selId || isOpen} onClick={deleteNet}>
|
<Button variant="ghost" size="sm" className="h-8 text-danger" disabled={!selId || isOpen} onClick={deleteNet}>
|
||||||
<Trash2 className="size-3.5" /> {t('ncp.delete')}
|
<Trash2 className="size-3.5" /> {t('ncp.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{isOpen && <Badge className="bg-emerald-600 text-white tracking-wider">{t('ncp.netOpenBadge')}</Badge>}
|
{isOpen && <Badge className="bg-success text-success-foreground tracking-wider">{t('ncp.netOpenBadge')}</Badge>}
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<span className="text-[11px] text-muted-foreground">
|
||||||
{t('ncp.onAir')} <strong className="text-foreground">{active.length}</strong> ·
|
{t('ncp.onAir')} <strong className="text-foreground">{active.length}</strong> ·
|
||||||
{t('ncp.roster')} <strong className="text-foreground">{roster.length}</strong>
|
{t('ncp.roster')} <strong className="text-foreground">{roster.length}</strong>
|
||||||
@@ -273,7 +288,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
{/* ACTIVE USERS (left) */}
|
{/* ACTIVE USERS (left) */}
|
||||||
<div className="flex flex-col min-h-0 flex-1 border-r border-border/60">
|
<div className="flex flex-col min-h-0 flex-1 border-r border-border/60">
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-600 text-white text-[11px] font-semibold uppercase tracking-wider">
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-destructive text-destructive-foreground text-[11px] font-semibold uppercase tracking-wider">
|
||||||
{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>
|
||||||
@@ -285,6 +300,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
rowData={active}
|
rowData={active}
|
||||||
columnDefs={activeCols}
|
columnDefs={activeCols}
|
||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
|
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
||||||
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
|
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
|
||||||
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
@@ -300,11 +316,66 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Worked-before for the clicked station — click a row (on air or
|
||||||
|
roster) to see if/when we contacted it before. Lives INSIDE the
|
||||||
|
on-air column so resizing only splits this column; the roster on
|
||||||
|
the right keeps its full height. */}
|
||||||
|
<div className="shrink-0 flex flex-col" style={{ height: wbHeight }}>
|
||||||
|
<div className="h-1.5 shrink-0 cursor-ns-resize border-t border-border/60 hover:bg-primary/40 transition-colors" onMouseDown={startWbResize} title={t('ncp.wbResize')} />
|
||||||
|
<div className="flex-1 min-h-0 bg-card flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||||
|
<History className="size-3.5" />
|
||||||
|
{t('ncp.workedBefore')}
|
||||||
|
{wbCall && <span className="font-mono text-foreground normal-case">{wbCall}</span>}
|
||||||
|
{wb && wb.count > 0 && (
|
||||||
|
<span className="ml-auto font-normal normal-case text-foreground">
|
||||||
|
{wb.count} QSO · {t('ncp.wbFirst')} {String(wb.first ?? '').slice(0, 10)} · {t('ncp.wbLast')} {String(wb.last ?? '').slice(0, 10)}
|
||||||
|
{wb.dxcc_name ? ` · ${wb.dxcc_name} (${wb.dxcc_count})` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
|
{!wbCall ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div>
|
||||||
|
) : wbBusy ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">…</div>
|
||||||
|
) : !wb || wb.count === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbNone')} {wbCall}</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-muted/30 text-muted-foreground">
|
||||||
|
<tr className="text-left">
|
||||||
|
<th className="px-3 py-1 font-semibold">{t('ncp.colDate')}</th>
|
||||||
|
<th className="px-2 py-1 font-semibold">{t('ncp.colTimeOn')}</th>
|
||||||
|
<th className="px-2 py-1 font-semibold">{t('ncp.colBand')}</th>
|
||||||
|
<th className="px-2 py-1 font-semibold">{t('ncp.colMode')}</th>
|
||||||
|
<th className="px-2 py-1 font-semibold">RST</th>
|
||||||
|
<th className="px-2 py-1 font-semibold">{t('ncp.colComment')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{((wb.entries ?? []) as any[]).map((q, i) => (
|
||||||
|
<tr key={q.id ?? i} className="border-t border-border/20 hover:bg-muted/30">
|
||||||
|
<td className="px-3 py-1 font-mono">{String(q.qso_date ?? '').slice(0, 10)}</td>
|
||||||
|
<td className="px-2 py-1 font-mono">{String(q.time_on ?? '').slice(0, 5)}</td>
|
||||||
|
<td className="px-2 py-1">{q.band}</td>
|
||||||
|
<td className="px-2 py-1">{q.mode}</td>
|
||||||
|
<td className="px-2 py-1 font-mono">{(q.rst_sent ?? '')}/{(q.rst_rcvd ?? '')}</td>
|
||||||
|
<td className="px-2 py-1 truncate max-w-[360px]">{q.comment}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* NET USERS / roster (right) */}
|
{/* NET USERS / roster (right) */}
|
||||||
<div className="flex flex-col min-h-0 w-[40%] max-w-[560px]">
|
<div className="flex flex-col min-h-0 w-[40%] max-w-[560px]">
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-emerald-700 text-white text-[11px] font-semibold uppercase tracking-wider">
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-success text-success-foreground text-[11px] font-semibold uppercase tracking-wider">
|
||||||
{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>
|
||||||
@@ -317,6 +388,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 }}
|
||||||
|
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}
|
||||||
getRowId={(p) => String((p.data as any).callsign)}
|
getRowId={(p) => String((p.data as any).callsign)}
|
||||||
@@ -327,7 +399,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" disabled={!selId} onClick={openAddContact}>
|
<Button variant="ghost" size="sm" className="h-7 text-[11px]" disabled={!selId} onClick={openAddContact}>
|
||||||
<UserPlus className="size-3.5" /> {t('ncp.addContact')}
|
<UserPlus className="size-3.5" /> {t('ncp.addContact')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-[11px] text-rose-700" disabled={!selId} onClick={removeSelectedRoster}>
|
<Button variant="ghost" size="sm" className="h-7 text-[11px] text-danger" disabled={!selId} onClick={removeSelectedRoster}>
|
||||||
<MinusCircle className="size-3.5" /> {t('ncp.remove')}
|
<MinusCircle className="size-3.5" /> {t('ncp.remove')}
|
||||||
</Button>
|
</Button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export function OperatingPanel({ bands, onError }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
||||||
{t('op.intro1')}<Star className="inline size-3 text-amber-500 fill-current align-text-bottom" />{t('op.intro2')}
|
{t('op.intro1')}<Star className="inline size-3 text-warning fill-current align-text-bottom" />{t('op.intro2')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -411,7 +411,7 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 px-2 py-1 rounded text-[11px] font-mono border transition-colors',
|
'flex items-center gap-1.5 px-2 py-1 rounded text-[11px] font-mono border transition-colors',
|
||||||
isDefault
|
isDefault
|
||||||
? 'border-amber-400 bg-amber-50 shadow-sm'
|
? 'border-warning-border bg-warning-muted shadow-sm'
|
||||||
: enabled
|
: enabled
|
||||||
? 'border-primary/30 bg-primary/5'
|
? 'border-primary/30 bg-primary/5'
|
||||||
: 'border-border/50 bg-muted/30 text-muted-foreground'
|
: 'border-border/50 bg-muted/30 text-muted-foreground'
|
||||||
@@ -430,8 +430,8 @@ function AntennaRow({ antenna, bands, editing, setEditing, onUpdate, onDelete }:
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-0.5 ml-1 px-1.5 py-0.5 rounded transition-colors',
|
'flex items-center gap-0.5 ml-1 px-1.5 py-0.5 rounded transition-colors',
|
||||||
isDefault
|
isDefault
|
||||||
? 'bg-amber-400 text-white'
|
? 'bg-warning text-warning-foreground'
|
||||||
: 'border border-dashed border-muted-foreground/40 text-muted-foreground hover:border-amber-500 hover:text-amber-700',
|
: 'border border-dashed border-muted-foreground/40 text-muted-foreground hover:border-warning hover:text-warning',
|
||||||
)}
|
)}
|
||||||
title={isDefault ? t('op.defaultOn') : t('op.defaultOff')}
|
title={isDefault ? t('op.defaultOn') : t('op.defaultOff')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react';
|
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react';
|
||||||
import { AllCommunityModule, ModuleRegistry, themeQuartz, type ColDef } from 'ag-grid-community';
|
import { AllCommunityModule, ModuleRegistry, type ColDef } from 'ag-grid-community';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -16,15 +16,6 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
// Warm theme matching the other grids (Recent QSOs / Cluster).
|
|
||||||
const qslTheme = themeQuartz.withParams({
|
|
||||||
fontFamily: 'inherit', fontSize: 12.5, backgroundColor: '#faf6ea', foregroundColor: '#2a2419',
|
|
||||||
headerBackgroundColor: '#e8dfc9', headerTextColor: '#5a4f3a', headerFontWeight: 600,
|
|
||||||
oddRowBackgroundColor: '#f5efe0', rowHoverColor: '#ecdcb4', selectedRowBackgroundColor: '#f0d9a8',
|
|
||||||
borderColor: '#c8b994', rowBorder: { color: '#d8c9a8', width: 1 }, columnBorder: { color: '#d8c9a8', width: 1 },
|
|
||||||
cellHorizontalPadding: 10, rowHeight: 30, headerHeight: 32, spacing: 4, accentColor: '#b8410c', iconSize: 12,
|
|
||||||
});
|
|
||||||
|
|
||||||
type UploadRow = {
|
type UploadRow = {
|
||||||
id: number; qso_date: string; callsign: string;
|
id: number; qso_date: string; callsign: string;
|
||||||
band: string; mode: string; country: string; status: string;
|
band: string; mode: string; country: string; status: string;
|
||||||
@@ -399,7 +390,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
|
|
||||||
{/* Content: log OR results grid */}
|
{/* Content: log OR results grid */}
|
||||||
<div className="flex-1 overflow-auto px-3 py-2 min-h-0">
|
<div className="flex-1 overflow-auto px-3 py-2 min-h-0">
|
||||||
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
|
{error && <div className="text-xs text-danger mb-2">{error}</div>}
|
||||||
|
|
||||||
{service === 'paper' ? (
|
{service === 'paper' ? (
|
||||||
paperRows.length === 0 ? (
|
paperRows.length === 0 ? (
|
||||||
@@ -425,7 +416,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
{potaSyncing && <div className="text-sm text-muted-foreground py-10 text-center flex items-center justify-center gap-2"><Loader2 className="size-4 animate-spin" /> {t('qslm.potaSyncing')}</div>}
|
{potaSyncing && <div className="text-sm text-muted-foreground py-10 text-center flex items-center justify-center gap-2"><Loader2 className="size-4 animate-spin" /> {t('qslm.potaSyncing')}</div>}
|
||||||
{potaRes && (
|
{potaRes && (
|
||||||
<>
|
<>
|
||||||
<div className="text-xs rounded-md px-3 py-2 border border-emerald-300 bg-emerald-50 text-emerald-800">
|
<div className="text-xs rounded-md px-3 py-2 border border-success-border bg-success-muted text-success-muted-foreground">
|
||||||
{t('qslm.potaSummary', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched, fetched: potaRes.fetched })}
|
{t('qslm.potaSummary', { updated: potaRes.updated, added: potaRes.added, already: potaRes.already_tagged, unmatched: potaRes.unmatched, fetched: potaRes.fetched })}
|
||||||
{potaRes.skipped_other_call > 0 && (
|
{potaRes.skipped_other_call > 0 && (
|
||||||
<>{t('qslm.potaSkipped', { n: potaRes.skipped_other_call })}{potaRes.my_call ? t('qslm.potaKeptOnly', { call: potaRes.my_call }) : ''}.</>
|
<>{t('qslm.potaSkipped', { n: potaRes.skipped_other_call })}{potaRes.my_call ? t('qslm.potaKeptOnly', { call: potaRes.my_call }) : ''}.</>
|
||||||
@@ -469,8 +460,8 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> {t('qslm.starting')}</div>
|
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> {t('qslm.starting')}</div>
|
||||||
) : logLines.map((l, i) => (
|
) : logLines.map((l, i) => (
|
||||||
<div key={i} className={cn(
|
<div key={i} className={cn(
|
||||||
/FAIL|failed|error/i.test(l) ? 'text-rose-700'
|
/FAIL|failed|error/i.test(l) ? 'text-danger'
|
||||||
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-emerald-700'
|
: /\bOK\b|UPDATED|ADDED|uploaded/i.test(l) ? 'text-success'
|
||||||
: 'text-foreground/90')}>{l}</div>
|
: 'text-foreground/90')}>{l}</div>
|
||||||
))}
|
))}
|
||||||
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> {t('qslm.working')}</div>}
|
{busy && <div className="text-muted-foreground flex items-center gap-2 pt-1"><Loader2 className="size-3 animate-spin" /> {t('qslm.working')}</div>}
|
||||||
@@ -498,9 +489,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<td className="py-1 px-2">{c.mode}</td>
|
<td className="py-1 px-2">{c.mode}</td>
|
||||||
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
||||||
<td className="py-1 px-2">
|
<td className="py-1 px-2">
|
||||||
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-rose-100 text-rose-800 border border-rose-300">{t('qslm.newDxcc')}</span>
|
{c.new_dxcc ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-danger-muted text-danger-muted-foreground border border-danger-border">{t('qslm.newDxcc')}</span>
|
||||||
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">{t('qslm.newBand')}</span>
|
: c.new_band ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border">{t('qslm.newBand')}</span>
|
||||||
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">{t('qslm.newSlot')}</span>
|
: c.new_slot ? <span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-caution-muted text-caution-muted-foreground border border-caution-border">{t('qslm.newSlot')}</span>
|
||||||
: <span className="text-muted-foreground/50">—</span>}
|
: <span className="text-muted-foreground/50">—</span>}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
|
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<RefreshCw className="size-4 text-sky-600" />
|
<RefreshCw className="size-4 text-info" />
|
||||||
<span>{t('qctx.updateQrz')}</span>
|
<span>{t('qctx.updateQrz')}</span>
|
||||||
</button>
|
</button>
|
||||||
{onUpdateFromClublog && (
|
{onUpdateFromClublog && (
|
||||||
@@ -83,7 +83,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onUpdateFromClublog(menu.ids); onClose(); }}
|
onClick={() => { onUpdateFromClublog(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<BadgeCheck className="size-4 text-violet-600" />
|
<BadgeCheck className="size-4 text-info" />
|
||||||
<span>{t('qctx.updateClublog')}</span>
|
<span>{t('qctx.updateClublog')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -96,7 +96,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onSendEQSL(menu.ids); onClose(); }}
|
onClick={() => { onSendEQSL(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<Mail className="size-4 text-amber-600" />
|
<Mail className="size-4 text-warning" />
|
||||||
<span>{t('qctx.sendQslEmail')}</span>
|
<span>{t('qctx.sendQslEmail')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -105,7 +105,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onSendRecording(menu.ids); onClose(); }}
|
onClick={() => { onSendRecording(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<Mail className="size-4 text-rose-600" />
|
<Mail className="size-4 text-danger" />
|
||||||
<span>{t('qctx.sendRecording')}</span>
|
<span>{t('qctx.sendRecording')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -119,7 +119,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onBulkEdit(menu.ids); onClose(); }}
|
onClick={() => { onBulkEdit(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<PencilLine className="size-4 text-indigo-600" />
|
<PencilLine className="size-4 text-info" />
|
||||||
<span>{t('qctx.bulkEdit', { n })}</span>
|
<span>{t('qctx.bulkEdit', { n })}</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -133,7 +133,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onExportSelected(menu.ids); onClose(); }}
|
onClick={() => { onExportSelected(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<FileDown className="size-4 text-sky-600" />
|
<FileDown className="size-4 text-info" />
|
||||||
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -142,7 +142,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onExportFiltered(); onClose(); }}
|
onClick={() => { onExportFiltered(); onClose(); }}
|
||||||
>
|
>
|
||||||
<FileDown className="size-4 text-violet-600" />
|
<FileDown className="size-4 text-info" />
|
||||||
<span>{t('qctx.exportFilteredAdif')}</span>
|
<span>{t('qctx.exportFilteredAdif')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -151,7 +151,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onExportCabrilloSelected(menu.ids); onClose(); }}
|
onClick={() => { onExportCabrilloSelected(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<FileDown className="size-4 text-amber-600" />
|
<FileDown className="size-4 text-warning" />
|
||||||
<span>{t('qctx.exportSelectedCabrillo', { n })}</span>
|
<span>{t('qctx.exportSelectedCabrillo', { n })}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -160,7 +160,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onExportCabrilloFiltered(); onClose(); }}
|
onClick={() => { onExportCabrilloFiltered(); onClose(); }}
|
||||||
>
|
>
|
||||||
<FileDown className="size-4 text-amber-700" />
|
<FileDown className="size-4 text-warning" />
|
||||||
<span>{t('qctx.exportFilteredCabrillo')}</span>
|
<span>{t('qctx.exportFilteredCabrillo')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -176,7 +176,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
onClick={() => { onSendTo(target.service, menu.ids); onClose(); }}
|
onClick={() => { onSendTo(target.service, menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<Upload className="size-4 text-emerald-600" />
|
<Upload className="size-4 text-success" />
|
||||||
<span>{t('qctx.sendTo', { name: target.name })}</span>
|
<span>{t('qctx.sendTo', { name: target.name })}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -187,7 +187,7 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<>
|
<>
|
||||||
<div className="my-1 border-t border-border" />
|
<div className="my-1 border-t border-border" />
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-rose-700 hover:bg-rose-50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-danger hover:bg-danger-muted"
|
||||||
onClick={() => { onDelete(menu.ids); onClose(); }}
|
onClick={() => { onDelete(menu.ids); onClose(); }}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
|
|||||||
@@ -81,10 +81,10 @@ function StatusCell({ value }: { value?: string }) {
|
|||||||
return <span className="block text-center text-[11px] text-muted-foreground">—</span>;
|
return <span className="block text-center text-[11px] text-muted-foreground">—</span>;
|
||||||
}
|
}
|
||||||
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
|
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
|
||||||
const cls = v === 'Y' ? 'bg-emerald-600 text-white'
|
const cls = v === 'Y' ? 'bg-success text-success-foreground'
|
||||||
: v === 'R' ? 'bg-orange-400 text-white'
|
: v === 'R' ? 'bg-warning text-warning-foreground'
|
||||||
: v === 'I' ? 'bg-stone-400 text-white'
|
: v === 'I' ? 'bg-muted-foreground text-background'
|
||||||
: 'bg-amber-400 text-amber-950';
|
: 'bg-warning text-warning-foreground';
|
||||||
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
|
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +187,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
// WPX, …) are derived from the QSO by the backend and shown read-only.
|
// WPX, …) are derived from the QSO by the backend and shown read-only.
|
||||||
const awardFieldRef = useRef<Record<string, string>>({});
|
const awardFieldRef = useRef<Record<string, string>>({});
|
||||||
const [awardRefs, setAwardRefs] = useState('');
|
const [awardRefs, setAwardRefs] = useState('');
|
||||||
|
// The refs present when the editor opened, so on save we can tell which ones
|
||||||
|
// were REMOVED and strip their in-field tokens (see applyAwardRefs).
|
||||||
|
const seedAwardRefsRef = useRef('');
|
||||||
const [computedRefs, setComputedRefs] = useState<Array<{ code: string; ref: string; name?: string }>>([]);
|
const [computedRefs, setComputedRefs] = useState<Array<{ code: string; ref: string; name?: string }>>([]);
|
||||||
|
|
||||||
// Load award definitions once, then seed the editable manual refs from the QSO.
|
// Load award definitions once, then seed the editable manual refs from the QSO.
|
||||||
@@ -207,6 +210,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
.filter((r: any) => r.pickable)
|
.filter((r: any) => r.pickable)
|
||||||
.map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`)
|
.map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`)
|
||||||
.join(';');
|
.join(';');
|
||||||
|
seedAwardRefsRef.current = seed;
|
||||||
setAwardRefs(seed);
|
setAwardRefs(seed);
|
||||||
} catch { /* leave manual refs empty on failure */ }
|
} catch { /* leave manual refs empty on failure */ }
|
||||||
})
|
})
|
||||||
@@ -327,7 +331,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
// the dedicated columns, then route the picked refs back onto the payload
|
// the dedicated columns, then route the picked refs back onto the payload
|
||||||
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
|
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
|
||||||
out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
|
out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
|
||||||
applyAwardRefs(out, awardRefs, awardFieldRef.current);
|
applyAwardRefs(out, awardRefs, awardFieldRef.current, seedAwardRefsRef.current);
|
||||||
onSave(out);
|
onSave(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
AllCommunityModule, ModuleRegistry,
|
||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
|
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 } from 'lucide-react';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
@@ -20,28 +21,8 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
// virtual-scroll — everything we want out of the box for a logbook table.
|
// virtual-scroll — everything we want out of the box for a logbook table.
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
// Custom Quartz theme tuned to match OpsLog's warm palette.
|
// Shared theme (follows the app palette); this table runs slightly taller rows.
|
||||||
const hamlogTheme = themeQuartz.withParams({
|
const hamlogTheme = hamlogGridTheme.withParams({ rowHeight: 32, headerHeight: 34 });
|
||||||
fontFamily: 'inherit',
|
|
||||||
fontSize: 12.5,
|
|
||||||
backgroundColor: '#faf6ea',
|
|
||||||
foregroundColor: '#2a2419',
|
|
||||||
headerBackgroundColor: '#e8dfc9',
|
|
||||||
headerTextColor: '#5a4f3a',
|
|
||||||
headerFontWeight: 600,
|
|
||||||
oddRowBackgroundColor: '#f5efe0',
|
|
||||||
rowHoverColor: '#ecdcb4',
|
|
||||||
selectedRowBackgroundColor: '#f0d9a8',
|
|
||||||
borderColor: '#c8b994',
|
|
||||||
rowBorder: { color: '#d8c9a8', width: 1 },
|
|
||||||
columnBorder: { color: '#d8c9a8', width: 1 },
|
|
||||||
cellHorizontalPadding: 10,
|
|
||||||
rowHeight: 32,
|
|
||||||
headerHeight: 34,
|
|
||||||
spacing: 4,
|
|
||||||
accentColor: '#b8410c',
|
|
||||||
iconSize: 12,
|
|
||||||
});
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
rows: QSOForm[];
|
rows: QSOForm[];
|
||||||
@@ -111,7 +92,7 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
// ── QSO basics ──
|
// ── QSO basics ──
|
||||||
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||||
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||||
{ group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', cellStyle: { color: '#b8410c' }, defaultVisible: true },
|
{ group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', defaultVisible: true },
|
||||||
{ group: 'QSO', label: t('rqg.c.band'), colId: 'band', headerName: t('rqg.c.band'), field: 'band' as any, width: 75, cellClass: 'font-mono', defaultVisible: true },
|
{ group: 'QSO', label: t('rqg.c.band'), colId: 'band', headerName: t('rqg.c.band'), field: 'band' as any, width: 75, cellClass: 'font-mono', defaultVisible: true },
|
||||||
{ group: 'QSO', label: t('rqg.c.band_rx'), colId: 'band_rx', headerName: t('rqg.c.band_rx'), field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
|
{ group: 'QSO', label: t('rqg.c.band_rx'), colId: 'band_rx', headerName: t('rqg.c.band_rx'), field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
|
||||||
{ group: 'QSO', label: t('rqg.c.mode'), colId: 'mode', headerName: t('rqg.c.mode'), field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
|
{ group: 'QSO', label: t('rqg.c.mode'), colId: 'mode', headerName: t('rqg.c.mode'), field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
|
||||||
|
|||||||
@@ -75,22 +75,22 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
|||||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
|
||||||
<Compass className="size-4 text-primary shrink-0" />
|
<Compass className="size-4 text-primary shrink-0" />
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span>
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span>
|
||||||
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')}
|
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||||
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
|
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{pattern && (
|
{pattern && (
|
||||||
<span
|
<span
|
||||||
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
|
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
|
||||||
pattern === 'reverse' ? 'bg-amber-200 text-amber-900'
|
pattern === 'reverse' ? 'bg-warning-muted text-warning-muted-foreground'
|
||||||
: pattern === 'bi' ? 'bg-sky-200 text-sky-900'
|
: pattern === 'bi' ? 'bg-info-muted text-info-muted-foreground'
|
||||||
: 'bg-emerald-200 text-emerald-900')}
|
: 'bg-success-muted text-success-muted-foreground')}
|
||||||
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
|
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
|
||||||
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
|
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
|
||||||
: 'Ultrabeam normal'}>
|
: 'Ultrabeam normal'}>
|
||||||
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
|
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="font-mono text-sm font-bold text-emerald-700 tabular-nums">
|
<span className="font-mono text-sm font-bold text-success tabular-nums">
|
||||||
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
|
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
|
||||||
</span>
|
</span>
|
||||||
{onClose && (
|
{onClose && (
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export function SendSpotModal({ open, onClose, defaultCall, defaultFreqKHz, defa
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <div className="text-xs text-rose-600">{error}</div>}
|
{error && <div className="text-xs text-danger">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||||
ChevronDown, ChevronRight,
|
ChevronDown, ChevronRight,
|
||||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power,
|
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||||
@@ -26,9 +26,8 @@ import {
|
|||||||
ConnectClusterServer, DisconnectClusterServer,
|
ConnectClusterServer, DisconnectClusterServer,
|
||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
|
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase,
|
||||||
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
||||||
GetDataDir,
|
|
||||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||||
GetLiveStatusEnabled, SetLiveStatusEnabled,
|
GetLiveStatusEnabled, SetLiveStatusEnabled,
|
||||||
@@ -59,6 +58,7 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||||
|
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
||||||
import { OperatingPanel } from '@/components/OperatingPanel';
|
import { OperatingPanel } from '@/components/OperatingPanel';
|
||||||
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
|
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
|
||||||
|
|
||||||
@@ -146,6 +146,7 @@ interface Props {
|
|||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update
|
onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update
|
||||||
flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane
|
flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane
|
||||||
|
icomAvailable?: boolean; // CAT backend is Icom → offer the Icom console as a Main pane
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pretty little card showing what OpsLog will stamp on each QSO based on
|
// Pretty little card showing what OpsLog will stamp on each QSO based on
|
||||||
@@ -333,6 +334,68 @@ function TreeNodeView({
|
|||||||
|
|
||||||
// ===== Section content panels =====
|
// ===== Section content panels =====
|
||||||
|
|
||||||
|
// Representative surface/card/accent swatch for each theme (picker preview —
|
||||||
|
// the theme is not applied until selected). Module-scope so their component
|
||||||
|
// identity is STABLE across SettingsModal re-renders; defining them inside the
|
||||||
|
// component would give each render a fresh function, remounting the Radix
|
||||||
|
// Select and slamming the open dropdown shut on any ambient re-render.
|
||||||
|
const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: string; accent: string }> = {
|
||||||
|
'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' },
|
||||||
|
'dark-warm': { bg: '#221d18', card: '#2e2820', accent: '#e07a2e' },
|
||||||
|
'dark-graphite': { bg: '#16181d', card: '#1f232b', accent: '#f97316' },
|
||||||
|
'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function ThemeSwatch({ theme }: { theme: Exclude<ThemeChoice, 'auto'> }) {
|
||||||
|
const s = THEME_SWATCH[theme];
|
||||||
|
return (
|
||||||
|
<span className="flex h-5 w-8 overflow-hidden rounded-[3px] border border-black/20 shrink-0" style={{ background: s.bg }}>
|
||||||
|
<span className="m-0.5 flex flex-1 items-center justify-center rounded-[2px]" style={{ background: s.card }}>
|
||||||
|
<span className="h-2 w-2 rounded-full" style={{ background: s.accent }} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto swatch = split light/dark, since it follows the OS preference.
|
||||||
|
function ThemeOptionSwatch({ theme }: { theme: ThemeChoice }) {
|
||||||
|
if (theme === 'auto') {
|
||||||
|
return (
|
||||||
|
<span className="flex h-5 w-8 overflow-hidden rounded-[3px] border border-black/20 shrink-0">
|
||||||
|
<span className="flex-1" style={{ background: '#e8dfc9' }} />
|
||||||
|
<span className="flex-1" style={{ background: '#16181d' }} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <ThemeSwatch theme={theme} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeSelector() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const options: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Label className="text-sm w-40">{t('settings.theme')}</Label>
|
||||||
|
<Select value={theme} onValueChange={(v) => setTheme(v as ThemeChoice)}>
|
||||||
|
<SelectTrigger className="h-9 w-64" title={t('settings.themeHint')}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<SelectItem key={opt} value={opt}>
|
||||||
|
<span className="flex items-center gap-2.5">
|
||||||
|
<ThemeOptionSwatch theme={opt} />
|
||||||
|
{t(`theme.${opt}`)}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SectionHeader({ title, hint }: { title: string; hint?: string }) {
|
function SectionHeader({ title, hint }: { title: string; hint?: string }) {
|
||||||
return (
|
return (
|
||||||
<header className="mb-4">
|
<header className="mb-4">
|
||||||
@@ -511,17 +574,19 @@ const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
|
|||||||
{ value: 'cluster', label: 'Cluster spots' },
|
{ value: 'cluster', label: 'Cluster spots' },
|
||||||
{ value: 'worked', label: 'Worked before' },
|
{ value: 'worked', label: 'Worked before' },
|
||||||
{ value: 'recent', label: 'Recent QSOs' },
|
{ value: 'recent', label: 'Recent QSOs' },
|
||||||
|
{ value: 'netcontrol', label: 'Net control' },
|
||||||
];
|
];
|
||||||
function MainViewPanes({ onChanged, flexAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean }) {
|
function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) {
|
||||||
const [left, setLeft] = useState('map1');
|
const [left, setLeft] = useState('map1');
|
||||||
const [right, setRight] = useState('map2');
|
const [right, setRight] = useState('map2');
|
||||||
// FlexRadio is only offered when the CAT backend is a Flex. Sorted A→Z.
|
// Radio-control panes are only offered when that CAT backend is active. Sorted A→Z.
|
||||||
const options = (flexAvailable
|
const options = [
|
||||||
? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }]
|
...MAIN_PANE_OPTIONS,
|
||||||
: [...MAIN_PANE_OPTIONS]
|
...(flexAvailable ? [{ value: 'flex', label: 'FlexRadio controls' }] : []),
|
||||||
).sort((a, b) => a.label.localeCompare(b.label));
|
...(icomAvailable ? [{ value: 'icom', label: 'Icom console' }] : []),
|
||||||
|
].sort((a, b) => a.label.localeCompare(b.label));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const valid = (v: string) => v === 'flex' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
|
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
|
||||||
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
||||||
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
|
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -646,7 +711,7 @@ function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{rxList.length === 0 && (
|
{rxList.length === 0 && (
|
||||||
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md px-3 py-2 max-w-xl">
|
<div className="text-xs text-warning-muted-foreground bg-warning-muted border border-warning-border rounded-md px-3 py-2 max-w-xl">
|
||||||
No antennas reported yet — make sure the FlexRadio is connected (CAT interface), then reopen this panel.
|
No antennas reported yet — make sure the FlexRadio is connected (CAT interface), then reopen this panel.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -694,7 +759,7 @@ function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable }: Props) {
|
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -731,7 +796,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [modeDraft, setModeDraft] = useState('');
|
const [modeDraft, setModeDraft] = useState('');
|
||||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false,
|
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false,
|
||||||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '',
|
||||||
|
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
||||||
digital_default: 'FT8',
|
digital_default: 'FT8',
|
||||||
});
|
});
|
||||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
const [rotator, setRotator] = useState<RotatorSettings>({
|
||||||
@@ -748,7 +814,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
// 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 }>({ enabled: false, host: '' });
|
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
||||||
|
|
||||||
// PowerGenius XL (4O3A) amp fan-control settings.
|
// PowerGenius XL (4O3A) amp fan-control settings.
|
||||||
const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9008 });
|
const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9008 });
|
||||||
@@ -925,7 +991,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [mysqlMsg, setMysqlMsg] = useState('');
|
const [mysqlMsg, setMysqlMsg] = useState('');
|
||||||
const [restartMsg, setRestartMsg] = useState(''); // backend switch / save → "restart to apply"
|
const [restartMsg, setRestartMsg] = useState(''); // backend switch / save → "restart to apply"
|
||||||
const [backendStatus, setBackendStatus] = useState<{ active: string; fallback: boolean; error: string } | null>(null);
|
const [backendStatus, setBackendStatus] = useState<{ active: string; fallback: boolean; error: string } | null>(null);
|
||||||
const [dataDir, setDataDir] = useState('');
|
|
||||||
|
|
||||||
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
|
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
|
||||||
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
|
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
|
||||||
@@ -1018,7 +1083,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
|
try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
|
||||||
try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
|
try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
|
||||||
try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
|
try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
|
||||||
try { setDataDir(await GetDataDir()); } catch {}
|
|
||||||
try {
|
try {
|
||||||
const locs: any = await ListTQSLStationLocations();
|
const locs: any = await ListTQSLStationLocations();
|
||||||
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
||||||
@@ -1474,7 +1538,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
disabled={!current}
|
disabled={!current}
|
||||||
/>
|
/>
|
||||||
{current?.is_active && (
|
{current?.is_active && (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold tracking-wider bg-emerald-100 text-emerald-800 border border-emerald-300">
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold tracking-wider bg-success-muted text-success-muted-foreground border border-success-border">
|
||||||
{t('prof.active')}
|
{t('prof.active')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1577,7 +1641,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
{testing ? t('lk.testing') : t('lk.test')}
|
{testing ? t('lk.testing') : t('lk.test')}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
<td className={cn('px-2 py-2 text-xs', test?.ok ? 'text-emerald-700' : 'text-destructive')}>
|
<td className={cn('px-2 py-2 text-xs', test?.ok ? 'text-success' : 'text-destructive')}>
|
||||||
{test?.msg}
|
{test?.msg}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1907,6 +1971,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<SelectItem value="omnirig">{t('cat.optOmnirig')}</SelectItem>
|
<SelectItem value="omnirig">{t('cat.optOmnirig')}</SelectItem>
|
||||||
<SelectItem value="flex">{t('cat.optFlex')}</SelectItem>
|
<SelectItem value="flex">{t('cat.optFlex')}</SelectItem>
|
||||||
<SelectItem value="icom">{t('cat.optIcom')}</SelectItem>
|
<SelectItem value="icom">{t('cat.optIcom')}</SelectItem>
|
||||||
|
<SelectItem value="icom-net">{t('cat.optIcomNet')}</SelectItem>
|
||||||
<SelectItem value="tci">{t('cat.optTci')}</SelectItem>
|
<SelectItem value="tci">{t('cat.optTci')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -1977,6 +2042,31 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{catCfg.backend === 'icom-net' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.icomNetHost')}</Label>
|
||||||
|
<Input placeholder="192.168.1.60" value={catCfg.icom_net_host ?? ''}
|
||||||
|
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_host: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.civAddr')}</Label>
|
||||||
|
<Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
|
||||||
|
onChange={(e) => { const n = parseInt(e.target.value.replace(/[^0-9a-fA-F]/g, ''), 16); setCatCfg((s) => ({ ...s, icom_addr: (n >= 0 && n <= 0xFF) ? n : s.icom_addr })); }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.icomNetUser')}</Label>
|
||||||
|
<Input value={catCfg.icom_net_user ?? ''}
|
||||||
|
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_user: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.icomNetPass')}</Label>
|
||||||
|
<Input type="password" value={catCfg.icom_net_pass ?? ''}
|
||||||
|
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_pass: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<p className="col-span-2 text-xs text-muted-foreground">{t('cat.icomNetHint')}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{catCfg.backend === 'tci' && (
|
{catCfg.backend === 'tci' && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -1998,7 +2088,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && (
|
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom' || catCfg.backend === 'icom-net') && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('cat.pollMs')}</Label>
|
<Label>{t('cat.pollMs')}</Label>
|
||||||
@@ -2143,7 +2233,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className={cn(
|
<div className={cn(
|
||||||
'text-xs rounded-md p-2.5 border',
|
'text-xs rounded-md p-2.5 border',
|
||||||
ubTest.ok
|
ubTest.ok
|
||||||
? 'bg-emerald-50 text-emerald-800 border-emerald-200'
|
? 'bg-success-muted text-success-muted-foreground border-success-border'
|
||||||
: 'bg-destructive/10 text-destructive border-destructive/30',
|
: 'bg-destructive/10 text-destructive border-destructive/30',
|
||||||
)}>
|
)}>
|
||||||
{ubTest.msg}
|
{ubTest.msg}
|
||||||
@@ -2175,6 +2265,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('ag2.password')}</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={antgenius.password ?? ''}
|
||||||
|
onChange={(e) => setAntgenius((s) => ({ ...s, password: e.target.value }))}
|
||||||
|
placeholder={t('ag2.passwordPh')}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('ag2.passwordHint')}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -2267,7 +2368,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className={cn(
|
<div className={cn(
|
||||||
'text-xs rounded-md p-2.5 border',
|
'text-xs rounded-md p-2.5 border',
|
||||||
rotatorTest.ok
|
rotatorTest.ok
|
||||||
? 'bg-emerald-50 text-emerald-800 border-emerald-200'
|
? 'bg-success-muted text-success-muted-foreground border-success-border'
|
||||||
: 'bg-destructive/10 text-destructive border-destructive/30',
|
: 'bg-destructive/10 text-destructive border-destructive/30',
|
||||||
)}>
|
)}>
|
||||||
{rotatorTest.msg}
|
{rotatorTest.msg}
|
||||||
@@ -2308,6 +2409,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
|
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
|
||||||
|
<SelectItem value="icom">Icom CI-V (rig keyer)</SelectItem>
|
||||||
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
|
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -2318,6 +2420,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{wk.engine === 'icom' ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-muted-foreground -mt-2">
|
||||||
|
Icom CI-V keys CW through the rig's own keyer over the existing CAT connection (command 0x17) — it reuses the CAT COM port set in Settings → CAT, so there's nothing else to wire up here. Put the rig in CW mode. Weight, ratio, sidetone, paddle mode… are configured on the radio; only the speed is set from here (the rig's KEY SPEED).
|
||||||
|
</p>
|
||||||
|
{(!catCfg.enabled || catCfg.backend !== 'icom') && (
|
||||||
|
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||||||
|
<span aria-hidden>⚠</span>
|
||||||
|
<span>
|
||||||
|
Your CAT backend is set to <strong>{catCfg.enabled ? (catCfg.backend || 'none') : 'disabled'}</strong>. Icom CI-V CW needs the CAT backend set to <strong>Icom</strong> and connected — change it under Settings → CAT interface, otherwise sending CW will fail.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Speed (WPM)</Label>
|
||||||
|
<Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label>Serial port</Label>
|
<Label>Serial port</Label>
|
||||||
@@ -2403,6 +2527,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> Serial echo
|
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> Serial echo
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Macro editor */}
|
{/* Macro editor */}
|
||||||
<div className="border-t border-border/60 pt-3">
|
<div className="border-t border-border/60 pt-3">
|
||||||
@@ -2500,16 +2626,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<td className="px-3 py-2 font-medium">
|
<td className="px-3 py-2 font-medium">
|
||||||
{s.name}
|
{s.name}
|
||||||
{isMaster && s.enabled && (
|
{isMaster && s.enabled && (
|
||||||
<span className="ml-2 inline-flex items-center px-1.5 py-0 rounded text-[9px] font-bold tracking-wider bg-amber-100 text-amber-800 border border-amber-300">MASTER</span>
|
<span className="ml-2 inline-flex items-center px-1.5 py-0 rounded text-[9px] font-bold tracking-wider bg-warning-muted text-warning-muted-foreground border border-warning-border">MASTER</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 font-mono text-xs">{s.host}:{s.port}</td>
|
<td className="px-3 py-2 font-mono text-xs">{s.host}:{s.port}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'inline-flex items-center px-1.5 py-0 rounded text-[9px] font-bold tracking-wider border',
|
'inline-flex items-center px-1.5 py-0 rounded text-[9px] font-bold tracking-wider border',
|
||||||
state === 'connected' ? 'bg-emerald-100 text-emerald-800 border-emerald-300' :
|
state === 'connected' ? 'bg-success-muted text-success-muted-foreground border-success-border' :
|
||||||
state === 'connecting' || state === 'reconnecting' ? 'bg-amber-100 text-amber-800 border-amber-300' :
|
state === 'connecting' || state === 'reconnecting' ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' :
|
||||||
state === 'error' ? 'bg-rose-100 text-rose-800 border-rose-300' :
|
state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
|
||||||
'bg-muted text-muted-foreground border-border',
|
'bg-muted text-muted-foreground border-border',
|
||||||
)}>
|
)}>
|
||||||
{state.toUpperCase()}
|
{state.toUpperCase()}
|
||||||
@@ -2519,7 +2645,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<td className="px-2 py-2">
|
<td className="px-2 py-2">
|
||||||
<div className="flex items-center gap-0.5 justify-end">
|
<div className="flex items-center gap-0.5 justify-end">
|
||||||
{state === 'connected' || state === 'connecting' || state === 'reconnecting' ? (
|
{state === 'connected' || state === 'connecting' || state === 'reconnecting' ? (
|
||||||
<Button variant="ghost" size="icon" className="size-6 text-emerald-600 hover:text-emerald-700"
|
<Button variant="ghost" size="icon" className="size-6 text-success hover:text-success"
|
||||||
onClick={async () => { await DisconnectClusterServer(s.id as number).catch(() => {}); await reloadClusterServers(); }}
|
onClick={async () => { await DisconnectClusterServer(s.id as number).catch(() => {}); await reloadClusterServers(); }}
|
||||||
title={t('clu.disconnect')}><Power className="size-3.5" /></Button>
|
title={t('clu.disconnect')}><Power className="size-3.5" /></Button>
|
||||||
) : (
|
) : (
|
||||||
@@ -2838,8 +2964,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className={cn(
|
<div className={cn(
|
||||||
'text-xs px-3 py-2 rounded-md border',
|
'text-xs px-3 py-2 rounded-md border',
|
||||||
backupResult.ok
|
backupResult.ok
|
||||||
? 'bg-emerald-50 border-emerald-300 text-emerald-800'
|
? 'bg-success-muted border-success-border text-success-muted-foreground'
|
||||||
: 'bg-rose-50 border-rose-300 text-rose-800',
|
: 'bg-danger-muted border-danger-border text-danger-muted-foreground',
|
||||||
)}>
|
)}>
|
||||||
{backupResult.msg}
|
{backupResult.msg}
|
||||||
</div>
|
</div>
|
||||||
@@ -3047,7 +3173,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<UploadCloud className="size-3.5" /> {qrzTesting ? t('es.testing') : t('es.testConn')}
|
<UploadCloud className="size-3.5" /> {qrzTesting ? t('es.testing') : t('es.testConn')}
|
||||||
</Button>
|
</Button>
|
||||||
{qrzTest && (
|
{qrzTest && (
|
||||||
<span className={cn('text-xs', qrzTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
|
<span className={cn('text-xs', qrzTest.ok ? 'text-success' : 'text-danger')}>
|
||||||
{qrzTest.msg}
|
{qrzTest.msg}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -3111,7 +3237,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<UploadCloud className="size-3.5" /> {clublogTesting ? t('es.testing') : t('es.testConn')}
|
<UploadCloud className="size-3.5" /> {clublogTesting ? t('es.testing') : t('es.testConn')}
|
||||||
</Button>
|
</Button>
|
||||||
{clublogTest && (
|
{clublogTest && (
|
||||||
<span className={cn('text-xs', clublogTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
|
<span className={cn('text-xs', clublogTest.ok ? 'text-success' : 'text-danger')}>
|
||||||
{clublogTest.msg}
|
{clublogTest.msg}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -3170,7 +3296,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<UploadCloud className="size-3.5" /> {hrdlogTesting ? t('es.testing') : t('es.testConn')}
|
<UploadCloud className="size-3.5" /> {hrdlogTesting ? t('es.testing') : t('es.testConn')}
|
||||||
</Button>
|
</Button>
|
||||||
{hrdlogTest && (
|
{hrdlogTest && (
|
||||||
<span className={cn('text-xs', hrdlogTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
|
<span className={cn('text-xs', hrdlogTest.ok ? 'text-success' : 'text-danger')}>
|
||||||
{hrdlogTest.msg}
|
{hrdlogTest.msg}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -3236,7 +3362,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<UploadCloud className="size-3.5" /> {eqslTesting ? t('es.testing') : t('es.testConn')}
|
<UploadCloud className="size-3.5" /> {eqslTesting ? t('es.testing') : t('es.testConn')}
|
||||||
</Button>
|
</Button>
|
||||||
{eqslTest && (
|
{eqslTest && (
|
||||||
<span className={cn('text-xs', eqslTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
|
<span className={cn('text-xs', eqslTest.ok ? 'text-success' : 'text-danger')}>
|
||||||
{eqslTest.msg}
|
{eqslTest.msg}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -3353,7 +3479,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<UploadCloud className="size-3.5" /> {lotwTesting ? t('es.testing') : t('es.testConn')}
|
<UploadCloud className="size-3.5" /> {lotwTesting ? t('es.testing') : t('es.testConn')}
|
||||||
</Button>
|
</Button>
|
||||||
{lotwTest && (
|
{lotwTest && (
|
||||||
<span className={cn('text-xs', lotwTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
|
<span className={cn('text-xs', lotwTest.ok ? 'text-success' : 'text-danger')}>
|
||||||
{lotwTest.msg}
|
{lotwTest.msg}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -3387,7 +3513,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
{potaResult && (
|
{potaResult && (
|
||||||
<div className={cn('text-xs rounded-md px-3 py-2 border',
|
<div className={cn('text-xs rounded-md px-3 py-2 border',
|
||||||
potaResult.ok ? 'border-emerald-300 bg-emerald-50 text-emerald-800' : 'border-destructive/30 bg-destructive/10 text-destructive')}>
|
potaResult.ok ? 'border-success-border bg-success-muted text-success-muted-foreground' : 'border-destructive/30 bg-destructive/10 text-destructive')}>
|
||||||
{potaResult.msg}
|
{potaResult.msg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -3407,13 +3533,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
|
|
||||||
function DatabasePanel() {
|
function DatabasePanel() {
|
||||||
async function refreshDb() { try { setDbSettings(await GetDatabaseSettings() as any); } catch {} }
|
async function refreshDb() { try { setDbSettings(await GetDatabaseSettings() as any); } catch {} }
|
||||||
|
async function refreshBackend() { try { setBackendStatus(await GetDBBackendStatus() as any); } catch {} }
|
||||||
|
// The chosen file is persisted (config.json) the moment it's picked; dbMsg
|
||||||
|
// holds the pending path so the banner can offer a one-click restart to load
|
||||||
|
// it (the DB pointer is only read at startup).
|
||||||
async function openExisting() {
|
async function openExisting() {
|
||||||
try {
|
try {
|
||||||
const p = await PickOpenDatabase();
|
const p = await PickOpenDatabase();
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
await OpenDatabase(p);
|
await OpenDatabase(p);
|
||||||
await refreshDb();
|
await refreshDb();
|
||||||
setDbMsg(`Database set to:\n${p}\nRestart OpsLog to apply.`);
|
setDbMsg(p);
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function saveCopy() {
|
async function saveCopy() {
|
||||||
@@ -3422,7 +3552,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
if (!p) return;
|
if (!p) return;
|
||||||
await MoveDatabase(p);
|
await MoveDatabase(p);
|
||||||
await refreshDb();
|
await refreshDb();
|
||||||
setDbMsg(`A copy was saved to:\n${p}\nand selected. Restart OpsLog to apply.`);
|
setDbMsg(p);
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function createNew() {
|
async function createNew() {
|
||||||
@@ -3431,29 +3561,45 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
if (!p) return;
|
if (!p) return;
|
||||||
await CreateDatabase(p);
|
await CreateDatabase(p);
|
||||||
await refreshDb();
|
await refreshDb();
|
||||||
setDbMsg(`New empty logbook created at:\n${p}\nand selected. Restart OpsLog to apply.`);
|
setDbMsg(p);
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function resetDefault() {
|
async function resetDefault() {
|
||||||
try {
|
try {
|
||||||
await ResetDatabaseToDefault();
|
await ResetDatabaseToDefault();
|
||||||
await refreshDb();
|
await refreshDb();
|
||||||
setDbMsg('Database reset to the default location. Restart OpsLog to apply.');
|
setDbMsg(dbSettings.default_path || '');
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
// Switching the logbook backend applies immediately (no restart): the local
|
||||||
|
// SQLite file always stays the config store; only the QSO logbook moves.
|
||||||
|
function useLocalLogbook() {
|
||||||
|
SaveMySQLSettings({ ...mysqlCfg, enabled: false } as any)
|
||||||
|
.then(async () => { setMysqlField({ enabled: false }); setRestartMsg(t('db.switchedSqlite')); await refreshBackend(); })
|
||||||
|
.catch((e: any) => setErr(String(e?.message ?? e)));
|
||||||
|
}
|
||||||
|
function connectMysql() {
|
||||||
|
SaveMySQLSettings(mysqlCfg as any)
|
||||||
|
.then(async () => { setRestartMsg(t('db.switchedMysql')); await refreshBackend(); })
|
||||||
|
.catch((e: any) => setErr(String(e?.message ?? e)));
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader title={t('sec.database')} />
|
||||||
title={t('sec.database')}
|
|
||||||
/>
|
{/* Logbook backend: local SQLite file (solo) or shared MySQL (multi-op).
|
||||||
{/* Backend selector for the ACTIVE PROFILE's logbook. Each profile can
|
Switching is instant — no restart. Only changing the local SQLite
|
||||||
target its own database; choosing here and Save switches the live
|
FILE needs a restart (it also stores this operator's settings). */}
|
||||||
logbook immediately (no restart). */}
|
|
||||||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
|
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
|
||||||
<Label className="text-sm">Backend</Label>
|
<Label className="text-sm">{t('db.backend')}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
|
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
|
||||||
onValueChange={(v) => setMysqlField({ enabled: v === 'mysql' })}
|
onValueChange={(v) => {
|
||||||
|
const enabled = v === 'mysql';
|
||||||
|
setMysqlField({ enabled });
|
||||||
|
setRestartMsg('');
|
||||||
|
if (!enabled) useLocalLogbook(); // switching to local applies at once
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -3462,50 +3608,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground max-w-2xl mb-3">
|
<p className="text-[11px] text-muted-foreground max-w-2xl mb-3">{t('db.profileHint')}</p>
|
||||||
{t('db.profileHint')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Save (always visible) applies the active profile's DB target live. */}
|
{/* Compact active-backend confirmation / MySQL-fallback warning. */}
|
||||||
<div className="max-w-2xl mb-4 flex items-center gap-3">
|
|
||||||
<Button size="sm" className="h-8"
|
|
||||||
onClick={() => {
|
|
||||||
SaveMySQLSettings(mysqlCfg as any)
|
|
||||||
.then(() => setRestartMsg(mysqlCfg.enabled
|
|
||||||
? t('db.switchedMysql')
|
|
||||||
: t('db.switchedSqlite')))
|
|
||||||
.catch((e: any) => setErr(String(e?.message ?? e)));
|
|
||||||
}}>
|
|
||||||
{t('db.saveSwitch')}
|
|
||||||
</Button>
|
|
||||||
{restartMsg && <span className="text-[11px] text-emerald-700">{restartMsg}</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active-backend status: confirms what OpsLog actually opened at launch. */}
|
|
||||||
{backendStatus && (
|
{backendStatus && (
|
||||||
<div className="max-w-2xl mb-4">
|
backendStatus.fallback ? (
|
||||||
{backendStatus.fallback ? (
|
<div className="max-w-2xl mb-4 text-xs bg-warning-muted border border-warning-border text-warning-muted-foreground rounded-md px-3 py-2">
|
||||||
<div className="text-xs bg-amber-50 border border-amber-300 text-amber-800 rounded-md px-3 py-2">
|
|
||||||
{t('db.fallback')}
|
{t('db.fallback')}
|
||||||
<div className="font-mono text-[10px] mt-1 break-all">{backendStatus.error}</div>
|
<div className="font-mono text-[10px] mt-1 break-all">{backendStatus.error}</div>
|
||||||
</div>
|
</div>
|
||||||
) : backendStatus.active === 'mysql' ? (
|
|
||||||
<div className="text-xs bg-muted/40 border border-border rounded-md px-3 py-2">
|
|
||||||
<strong>{t('db.logbook')}</strong> {t('db.mysqlConnected')}
|
|
||||||
<span className="mx-1.5 text-muted-foreground">·</span>
|
|
||||||
<strong>{t('db.config')}</strong> {t('db.localSqlite')}
|
|
||||||
<div className="text-[10px] text-muted-foreground mt-1">
|
|
||||||
{t('db.mysqlNote')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs bg-muted/40 border border-border rounded-md px-3 py-2">
|
<div className="max-w-2xl mb-4 text-[11px] text-muted-foreground">
|
||||||
{t('db.activeBackend')} <strong className="uppercase">{backendStatus.active}</strong>
|
{t('db.activeBackend')} <strong className="uppercase text-foreground">{backendStatus.active}</strong>
|
||||||
</div>
|
{backendStatus.active === 'mysql' && <span> · {t('db.configLocal')}</span>}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* SQLite: local logbook file management */}
|
||||||
{!mysqlCfg.enabled && (
|
{!mysqlCfg.enabled && (
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -3513,7 +3633,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
|
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
|
||||||
{dbSettings.path || '—'}
|
{dbSettings.path || '—'}
|
||||||
{dbSettings.is_custom
|
{dbSettings.is_custom
|
||||||
? <span className="ml-2 text-[10px] text-emerald-700">{t('db.customLoc')}</span>
|
? <span className="ml-2 text-[10px] text-success">{t('db.customLoc')}</span>
|
||||||
: <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
|
: <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground">{t('db.defaultLabel')} <span className="font-mono">{dbSettings.default_path}</span></div>
|
<div className="text-[10px] text-muted-foreground">{t('db.defaultLabel')} <span className="font-mono">{dbSettings.default_path}</span></div>
|
||||||
@@ -3521,26 +3641,35 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
|
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
|
||||||
<Button variant="outline" size="sm" onClick={openExisting}>{t('db.openExisting')}</Button>
|
<Button variant="outline" size="sm" onClick={openExisting}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
|
||||||
<Button variant="outline" size="sm" onClick={saveCopy}>{t('db.saveCopy')}</Button>
|
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
|
||||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dbMsg && (
|
{dbMsg && (
|
||||||
<div className="text-xs bg-emerald-50 border border-emerald-300 text-emerald-800 rounded-md px-3 py-2 flex items-center justify-between gap-3 whitespace-pre-line">
|
<div className="text-xs bg-success-muted border border-success-border text-success-muted-foreground rounded-md px-3 py-3 space-y-2">
|
||||||
<span>{dbMsg}</span>
|
<div className="flex items-start gap-2">
|
||||||
<Button size="sm" variant="destructive" className="shrink-0" onClick={() => QuitApp()}>{t('db.quitNow')}</Button>
|
<Check className="size-4 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{t('db.savedRestart')}</div>
|
||||||
|
<div className="font-mono text-[10px] mt-1 break-all opacity-90">{dbMsg}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pl-6">
|
||||||
|
<Button size="sm" onClick={() => { RestartApp().catch((e: any) => setErr(String(e?.message ?? e))); }}>
|
||||||
|
<Power className="size-3.5" /> {t('db.restartNow')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-[10px] opacity-80">{t('db.restartHint')}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Shared MySQL database (multi-operator) */}
|
{/* MySQL: shared logbook connection (multi-operator) */}
|
||||||
{mysqlCfg.enabled && (
|
{mysqlCfg.enabled && (
|
||||||
<div className="space-y-3 max-w-2xl">
|
<div className="space-y-3 max-w-2xl">
|
||||||
<div className="text-[11px] text-muted-foreground leading-relaxed">
|
<div className="text-[11px] text-muted-foreground leading-relaxed">{t('db.mysqlHint')}</div>
|
||||||
{t('db.mysqlHint')}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||||||
<Label className="text-sm">{t('db.host')}</Label>
|
<Label className="text-sm">{t('db.host')}</Label>
|
||||||
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
|
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
|
||||||
@@ -3553,28 +3682,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Label className="text-sm">{t('es.password')}</Label>
|
<Label className="text-sm">{t('es.password')}</Label>
|
||||||
<Input type="password" className="h-8" value={mysqlCfg.password} onChange={(e) => setMysqlField({ password: e.target.value })} />
|
<Input type="password" className="h-8" value={mysqlCfg.password} onChange={(e) => setMysqlField({ password: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<Button variant="outline" size="sm" className="h-8"
|
<Button variant="outline" size="sm" className="h-8"
|
||||||
onClick={() => { setMysqlMsg(t('db.testing')); TestMySQLConnection(mysqlCfg as any).then(() => setMysqlMsg(t('db.connectedReady'))).catch((e: any) => setMysqlMsg(t('db.failed') + String(e?.message ?? e))); }}>
|
onClick={() => { setMysqlMsg(t('db.testing')); TestMySQLConnection(mysqlCfg as any).then(() => setMysqlMsg(t('db.connectedReady'))).catch((e: any) => setMysqlMsg(t('db.failed') + String(e?.message ?? e))); }}>
|
||||||
{t('db.testCreate')}
|
{t('db.testCreate')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" className="h-8" onClick={connectMysql}>{t('db.connectUse')}</Button>
|
||||||
<span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
|
<span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Data location */}
|
{restartMsg && <div className="max-w-2xl mt-3 text-[11px] text-success">{restartMsg}</div>}
|
||||||
<div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium">{t('db.dataLocation')}</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>{t('db.currentDataDir')}</Label>
|
|
||||||
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
|
|
||||||
{dataDir || '—'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Backup settings, merged into this Database section. */}
|
{/* Backup settings, merged into this Database section. */}
|
||||||
<div className="border-t border-border/60 mt-6 pt-5">
|
<div className="border-t border-border/60 mt-6 pt-5">
|
||||||
@@ -3790,6 +3909,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ThemeSelector />
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} />
|
<Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} />
|
||||||
{t('gen.autofocusWB')}
|
{t('gen.autofocusWB')}
|
||||||
@@ -3813,7 +3934,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<TelemetryToggle />
|
<TelemetryToggle />
|
||||||
<LiveStatusToggle />
|
<LiveStatusToggle />
|
||||||
|
|
||||||
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} />
|
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} />
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||||
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
|
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
|
||||||
@@ -3822,8 +3943,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Passwords encrypted{' '}
|
Passwords encrypted{' '}
|
||||||
{secret.unlocked
|
{secret.unlocked
|
||||||
? <span className="text-emerald-700 font-medium">— unlocked</span>
|
? <span className="text-success font-medium">— unlocked</span>
|
||||||
: <span className="text-amber-600 font-medium">— locked (unlock at launch)</span>}.
|
: <span className="text-warning font-medium">— locked (unlock at launch)</span>}.
|
||||||
</p>
|
</p>
|
||||||
{secret.unlocked && (
|
{secret.unlocked && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -37,15 +37,15 @@ export function ShutdownProgress() {
|
|||||||
) : steps.map((s) => (
|
) : steps.map((s) => (
|
||||||
<div key={s.id} className="flex items-start gap-2 text-sm">
|
<div key={s.id} className="flex items-start gap-2 text-sm">
|
||||||
<div className="mt-0.5 w-4 flex items-center justify-center">
|
<div className="mt-0.5 w-4 flex items-center justify-center">
|
||||||
{s.status === 'done' && <CheckCircle2 className="size-4 text-emerald-600" />}
|
{s.status === 'done' && <CheckCircle2 className="size-4 text-success" />}
|
||||||
{s.status === 'running' && <Loader2 className="size-4 animate-spin text-primary" />}
|
{s.status === 'running' && <Loader2 className="size-4 animate-spin text-primary" />}
|
||||||
{s.status === 'error' && <XCircle className="size-4 text-rose-600" />}
|
{s.status === 'error' && <XCircle className="size-4 text-danger" />}
|
||||||
{s.status === 'pending' && <span className="size-2 rounded-full bg-muted-foreground/40" />}
|
{s.status === 'pending' && <span className="size-2 rounded-full bg-muted-foreground/40" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className={
|
<div className={
|
||||||
s.status === 'done' ? 'text-foreground'
|
s.status === 'done' ? 'text-foreground'
|
||||||
: s.status === 'error' ? 'text-rose-700 font-medium'
|
: s.status === 'error' ? 'text-danger font-medium'
|
||||||
: s.status === 'pending' ? 'text-muted-foreground'
|
: s.status === 'pending' ? 'text-muted-foreground'
|
||||||
: 'text-foreground font-medium'
|
: 'text-foreground font-medium'
|
||||||
}>
|
}>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type UDPConfig = {
|
|||||||
direction: 'inbound' | 'outbound';
|
direction: 'inbound' | 'outbound';
|
||||||
name: string;
|
name: string;
|
||||||
port: number;
|
port: number;
|
||||||
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated';
|
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated' | 'pstrotator_freq' | 'n1mm_radioinfo';
|
||||||
multicast: boolean;
|
multicast: boolean;
|
||||||
multicast_group: string;
|
multicast_group: string;
|
||||||
destination_ip: string;
|
destination_ip: string;
|
||||||
@@ -77,6 +77,20 @@ const SERVICE_TYPES: Array<{
|
|||||||
hint: 'udpp.svcDbHint',
|
hint: 'udpp.svcDbHint',
|
||||||
defaults: { port: 2333, destination_ip: '127.0.0.1' },
|
defaults: { port: 2333, destination_ip: '127.0.0.1' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'pstrotator_freq',
|
||||||
|
direction: 'outbound',
|
||||||
|
label: 'udpp.svcPstLabel',
|
||||||
|
hint: 'udpp.svcPstHint',
|
||||||
|
defaults: { port: 12040, destination_ip: '127.0.0.1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'n1mm_radioinfo',
|
||||||
|
direction: 'outbound',
|
||||||
|
label: 'udpp.svcN1mmRadioLabel',
|
||||||
|
hint: 'udpp.svcN1mmRadioHint',
|
||||||
|
defaults: { port: 12060, destination_ip: '127.0.0.1' },
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
type Props = { onError: (msg: string) => void };
|
type Props = { onError: (msg: string) => void };
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ interface Props {
|
|||||||
wpm: number;
|
wpm: number;
|
||||||
macros: WKMacro[];
|
macros: WKMacro[];
|
||||||
sent: string; // text echoed back by the keyer as it transmits
|
sent: string; // text echoed back by the keyer as it transmits
|
||||||
|
source: 'winkeyer' | 'icom'; // CW output engine (chosen in Settings → CW Keyer)
|
||||||
|
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||||
|
onSetBreakIn?: (mode: number) => void;
|
||||||
onSelectPort: (p: string) => void;
|
onSelectPort: (p: string) => void;
|
||||||
onRefreshPorts: () => void;
|
onRefreshPorts: () => void;
|
||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
@@ -49,7 +52,7 @@ interface Props {
|
|||||||
// reserved space to the right of the F1-F5 tabs. Sends Morse via the WinKeyer
|
// reserved space to the right of the F1-F5 tabs. Sends Morse via the WinKeyer
|
||||||
// hardware: free-text CW, one-click macros (F1…), live speed, and abort.
|
// hardware: free-text CW, one-click macros (F1…), live speed, and abort.
|
||||||
export function WinkeyerPanel({
|
export function WinkeyerPanel({
|
||||||
status, ports, port, wpm, macros, sent,
|
status, ports, port, wpm, macros, sent, source, breakIn = 0, onSetBreakIn,
|
||||||
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
|
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
|
||||||
onSend, onSendMacro, onStop, onClose,
|
onSend, onSendMacro, onStop, onClose,
|
||||||
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
|
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
|
||||||
@@ -96,11 +99,18 @@ export function WinkeyerPanel({
|
|||||||
{/* Header / connection bar */}
|
{/* Header / connection bar */}
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
|
||||||
<Radio className="size-4 text-primary shrink-0" />
|
<Radio className="size-4 text-primary shrink-0" />
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">WinKeyer</span>
|
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500') : 'bg-muted-foreground/40')}
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||||
|
{source === 'icom' ? 'Icom CW' : 'WinKeyer'}
|
||||||
|
</span>
|
||||||
|
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{!connected ? (
|
{source === 'icom' ? (
|
||||||
|
<span className="text-[11px] font-medium text-muted-foreground">
|
||||||
|
{connected ? t('wkp.civReady') : t('wkp.civOffline')}
|
||||||
|
</span>
|
||||||
|
) : !connected ? (
|
||||||
<>
|
<>
|
||||||
<Select value={port || '_'} onValueChange={(v) => onSelectPort(v === '_' ? '' : v)}>
|
<Select value={port || '_'} onValueChange={(v) => onSelectPort(v === '_' ? '' : v)}>
|
||||||
<SelectTrigger className="h-7 w-28 text-xs"><SelectValue placeholder={t('wkp.comPort')} /></SelectTrigger>
|
<SelectTrigger className="h-7 w-28 text-xs"><SelectValue placeholder={t('wkp.comPort')} /></SelectTrigger>
|
||||||
@@ -132,7 +142,7 @@ export function WinkeyerPanel({
|
|||||||
<Label className="text-xs w-8 shrink-0">TX</Label>
|
<Label className="text-xs w-8 shrink-0">TX</Label>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'flex-1 min-w-0 h-8 rounded-md border border-border bg-muted/30 px-2.5 flex items-center font-mono text-sm tracking-wide truncate',
|
'flex-1 min-w-0 h-8 rounded-md border border-border bg-muted/30 px-2.5 flex items-center font-mono text-sm tracking-wide truncate',
|
||||||
status.busy ? 'text-emerald-700' : 'text-muted-foreground',
|
status.busy ? 'text-success' : 'text-muted-foreground',
|
||||||
)}>
|
)}>
|
||||||
{sent || <span className="opacity-50">—</span>}
|
{sent || <span className="opacity-50">—</span>}
|
||||||
{status.busy && <span className="ml-0.5 animate-pulse">▌</span>}
|
{status.busy && <span className="ml-0.5 animate-pulse">▌</span>}
|
||||||
@@ -150,17 +160,37 @@ export function WinkeyerPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Break-in (Icom only): the rig's 0x17 keyer only transmits when break-in
|
||||||
|
is on — SEMI or FULL. OFF keys the sidetone but stays in RX. */}
|
||||||
|
{source === 'icom' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs w-16 shrink-0" title={t('wkp.breakInHint')}>{t('wkp.breakIn')}</Label>
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
{[{ v: 0, l: t('wkp.bkOff') }, { v: 1, l: 'SEMI' }, { v: 2, l: 'FULL' }].map((o) => (
|
||||||
|
<button key={o.v} type="button" disabled={!connected} onClick={() => onSetBreakIn?.(o.v)}
|
||||||
|
className={cn('px-2.5 py-1 text-[11px] font-bold tracking-wide border-l border-border first:border-l-0 transition-colors disabled:opacity-40',
|
||||||
|
breakIn === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{o.l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{breakIn === 0 && <span className="text-[10px] text-warning">{t('wkp.bkOffWarn')}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* CW text */}
|
{/* CW text */}
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
||||||
{t('wkp.cwText')}
|
{t('wkp.cwText')}
|
||||||
|
{source === 'winkeyer' && (
|
||||||
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
||||||
title={t('wkp.sendOnTypeHint')}>
|
title={t('wkp.sendOnTypeHint')}>
|
||||||
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
||||||
onChange={(e) => onToggleSendOnType(e.target.checked)} />
|
onChange={(e) => onToggleSendOnType(e.target.checked)} />
|
||||||
{t('wkp.sendOnType')}
|
{t('wkp.sendOnType')}
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={cwText}
|
value={cwText}
|
||||||
@@ -195,7 +225,7 @@ export function WinkeyerPanel({
|
|||||||
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
|
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
|
||||||
<span className="text-[9px] text-muted-foreground">sec</span>
|
<span className="text-[9px] text-muted-foreground">sec</span>
|
||||||
</div>
|
</div>
|
||||||
{autoCall && <span className="text-[10px] text-amber-600/80">{t('wkp.loopHint')}</span>}
|
{autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
||||||
@@ -217,7 +247,7 @@ export function WinkeyerPanel({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{status.error && <div className="text-[11px] text-rose-600">{status.error}</div>}
|
{status.error && <div className="text-[11px] text-danger">{status.error}</div>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
AllCommunityModule, ModuleRegistry,
|
||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Columns3, FilterX, Star } from 'lucide-react';
|
import { Columns3, FilterX, Star } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -19,27 +20,7 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
const hamlogTheme = themeQuartz.withParams({
|
const hamlogTheme = hamlogGridTheme;
|
||||||
fontFamily: 'inherit',
|
|
||||||
fontSize: 12.5,
|
|
||||||
backgroundColor: '#faf6ea',
|
|
||||||
foregroundColor: '#2a2419',
|
|
||||||
headerBackgroundColor: '#e8dfc9',
|
|
||||||
headerTextColor: '#5a4f3a',
|
|
||||||
headerFontWeight: 600,
|
|
||||||
oddRowBackgroundColor: '#f5efe0',
|
|
||||||
rowHoverColor: '#ecdcb4',
|
|
||||||
selectedRowBackgroundColor: '#f0d9a8',
|
|
||||||
borderColor: '#c8b994',
|
|
||||||
rowBorder: { color: '#d8c9a8', width: 1 },
|
|
||||||
columnBorder: { color: '#d8c9a8', width: 1 },
|
|
||||||
cellHorizontalPadding: 10,
|
|
||||||
rowHeight: 30,
|
|
||||||
headerHeight: 32,
|
|
||||||
spacing: 4,
|
|
||||||
accentColor: '#b8410c',
|
|
||||||
iconSize: 12,
|
|
||||||
});
|
|
||||||
|
|
||||||
type WorkedEntry = QSOForm; // entries are now full QSO records
|
type WorkedEntry = QSOForm; // entries are now full QSO records
|
||||||
|
|
||||||
|
|||||||
@@ -292,6 +292,7 @@ const QSO_FIELD_WEIGHT: Record<string, number> = {
|
|||||||
|
|
||||||
// QSOBoxView renders the confirmation box with per-QSO values.
|
// QSOBoxView renders the confirmation box with per-QSO values.
|
||||||
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
|
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
|
||||||
|
const fg = box.fg || '#14243a'; // text colour (field labels use it at 0.6 opacity)
|
||||||
const avail = box.w - 56;
|
const avail = box.w - 56;
|
||||||
const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
|
const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
|
||||||
let cursor = 28;
|
let cursor = 28;
|
||||||
@@ -304,24 +305,24 @@ function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, strin
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
|
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
|
||||||
<text x={28} y={22} fontSize={34} fontWeight={700} fill="#1b2a3d"
|
<text x={28} y={22} fontSize={34} fontWeight={700} fill={fg}
|
||||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||||
{box.title}
|
{box.title}
|
||||||
</text>
|
</text>
|
||||||
{cols.map(({ f, x }) => (
|
{cols.map(({ f, x }) => (
|
||||||
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
|
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
|
||||||
<text fontSize={19} fill="#6b7a8c" letterSpacing={1.5}
|
<text fontSize={19} fill={fg} opacity={0.6} letterSpacing={1.5}
|
||||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||||
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
|
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
|
||||||
</text>
|
</text>
|
||||||
<text y={26} fontSize={28} fontWeight={700} fill="#14243a"
|
<text y={26} fontSize={28} fontWeight={700} fill={fg}
|
||||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||||
{values[f] ?? ''}
|
{values[f] ?? ''}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
{box.footer && (
|
{box.footer && (
|
||||||
<text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill="#3c4d63"
|
<text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill={fg} opacity={0.85}
|
||||||
fontFamily="'Segoe UI', Arial, sans-serif">
|
fontFamily="'Segoe UI', Arial, sans-serif">
|
||||||
{box.footer}
|
{box.footer}
|
||||||
</text>
|
</text>
|
||||||
|
|||||||
@@ -170,6 +170,35 @@ export function EditorPanel({ template, sel, presets, fontFamilies, onPatchEleme
|
|||||||
onChange={(w) => onPatchBox({ w })} />
|
onChange={(w) => onPatchBox({ w })} />
|
||||||
<NumberField label="Height" value={box.h} min={120} max={500}
|
<NumberField label="Height" value={box.h} min={120} max={500}
|
||||||
onChange={(h) => onPatchBox({ h })} />
|
onChange={(h) => onPatchBox({ h })} />
|
||||||
|
<NumberField label="Opacity %" value={Math.round((box.bg_opacity ?? 1) * 100)} min={0} max={100}
|
||||||
|
onChange={(v) => onPatchBox({ bg_opacity: Math.max(0, Math.min(100, v)) / 100 })} />
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Background</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="color" className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-0"
|
||||||
|
value={/^#[0-9a-fA-F]{6}$/.test(box.bg ?? '') ? box.bg : '#000000'}
|
||||||
|
onChange={(ev) => onPatchBox({ bg: ev.target.value })} />
|
||||||
|
<Input className="h-7 w-32 font-mono text-xs" value={box.bg ?? ''}
|
||||||
|
onChange={(ev) => onPatchBox({ bg: ev.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Text color</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="color" className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-0"
|
||||||
|
value={/^#[0-9a-fA-F]{6}$/.test(box.fg ?? '') ? (box.fg as string) : '#14243a'}
|
||||||
|
onChange={(ev) => onPatchBox({ fg: ev.target.value })} />
|
||||||
|
<Input className="h-7 w-32 font-mono text-xs" value={box.fg ?? ''} placeholder="#14243a"
|
||||||
|
onChange={(ev) => onPatchBox({ fg: ev.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NumberField label="Corner radius" value={box.radius ?? 0} min={0} max={80}
|
||||||
|
onChange={(radius) => onPatchBox({ radius })} />
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Title</Label>
|
||||||
|
<Input className="h-7 w-44 font-mono text-xs" value={box.title ?? ''}
|
||||||
|
onChange={(ev) => onPatchBox({ title: ev.target.value })} />
|
||||||
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Label className="text-xs text-muted-foreground">Footer</Label>
|
<Label className="text-xs text-muted-foreground">Footer</Label>
|
||||||
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
|
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
<DialogContent className="max-w-[1260px]">
|
<DialogContent className="max-w-[1260px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Sparkles className="size-5 text-amber-500" />
|
<Sparkles className="size-5 text-warning" />
|
||||||
QSL card designer
|
QSL card designer
|
||||||
{view !== 'home' && (
|
{view !== 'home' && (
|
||||||
<Button variant="ghost" size="sm" className="ml-2 h-7 px-2 text-xs"
|
<Button variant="ghost" size="sm" className="ml-2 h-7 px-2 text-xs"
|
||||||
@@ -246,7 +246,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
|
|
||||||
<div className="max-h-[78vh] space-y-4 overflow-y-auto px-6 py-5">
|
<div className="max-h-[78vh] space-y-4 overflow-y-auto px-6 py-5">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">{error}</div>
|
<div className="rounded border border-destructive bg-destructive-muted px-3 py-1.5 text-sm text-destructive-muted-foreground">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{view === 'home' && (
|
{view === 'home' && (
|
||||||
@@ -284,7 +284,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
: <div className="flex h-28 items-center justify-center rounded bg-muted text-xs text-muted-foreground">no preview</div>}
|
: <div className="flex h-28 items-center justify-center rounded bg-muted text-xs text-muted-foreground">no preview</div>}
|
||||||
<div className="mt-1.5 flex items-center justify-between gap-1">
|
<div className="mt-1.5 flex items-center justify-between gap-1">
|
||||||
<span className="truncate text-sm font-medium">
|
<span className="truncate text-sm font-medium">
|
||||||
{t.is_default && <Star className="mr-1 inline size-3.5 fill-amber-400 text-amber-400" />}
|
{t.is_default && <Star className="mr-1 inline size-3.5 fill-warning text-warning" />}
|
||||||
{t.name}
|
{t.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex shrink-0 gap-0.5">
|
<span className="flex shrink-0 gap-0.5">
|
||||||
@@ -299,7 +299,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" size="sm"
|
<Button variant="ghost" size="sm"
|
||||||
className={`h-7 p-0 ${deleteArm === t.id ? 'w-auto px-1.5 text-red-600' : 'w-7'}`}
|
className={`h-7 p-0 ${deleteArm === t.id ? 'w-auto px-1.5 text-destructive' : 'w-7'}`}
|
||||||
title="Delete" onClick={() => removeTemplate(t.id)}>
|
title="Delete" onClick={() => removeTemplate(t.id)}>
|
||||||
{deleteArm === t.id ? <span className="text-xs">Sure?</span> : <Trash2 className="size-3.5" />}
|
{deleteArm === t.id ? <span className="text-xs">Sure?</span> : <Trash2 className="size-3.5" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -323,7 +323,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
{proposals.map((p, i) => (
|
{proposals.map((p, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
className="rounded-md border-2 border-transparent p-1 transition hover:border-sky-400"
|
className="rounded-md border-2 border-transparent p-1 transition hover:border-info"
|
||||||
onClick={() => openEditor(0, p.template, p.model, p.assets)}
|
onClick={() => openEditor(0, p.template, p.model, p.assets)}
|
||||||
>
|
>
|
||||||
<CardPreview model={p.model} assets={p.assets} width={352} />
|
<CardPreview model={p.model} assets={p.assets} width={352} />
|
||||||
|
|||||||
@@ -75,13 +75,13 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
|
|||||||
<DialogContent className="max-w-[820px]">
|
<DialogContent className="max-w-[820px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Mail className="size-5 text-rose-600" /> Send OpsLog QSL by e-mail
|
<Mail className="size-5 text-danger" /> Send OpsLog QSL by e-mail
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 px-6 py-5">
|
<div className="space-y-3 px-6 py-5">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">{error}</div>
|
<div className="rounded border border-destructive bg-destructive-muted px-3 py-1.5 text-sm text-destructive-muted-foreground">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{open && !templateId && !error && (
|
{open && !templateId && !error && (
|
||||||
@@ -107,7 +107,7 @@ export function SendEQSLModal({ open, qsoId, onClose, onOpenDesigner }: Props) {
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{sent ? (
|
{sent ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-emerald-600">
|
<div className="flex items-center gap-2 text-sm text-success">
|
||||||
<CheckCircle2 className="size-4" /> OpsLog QSL sent.
|
<CheckCircle2 className="size-4" /> OpsLog QSL sent.
|
||||||
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
|
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export interface QSOBox {
|
|||||||
h: number;
|
h: number;
|
||||||
bg: string;
|
bg: string;
|
||||||
bg_opacity: number;
|
bg_opacity: number;
|
||||||
|
fg?: string; // text colour (default dark); set it when using a dark background
|
||||||
radius: number;
|
radius: number;
|
||||||
title: string;
|
title: string;
|
||||||
fields: string[];
|
fields: string[];
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const TooltipContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
'z-50 overflow-hidden rounded-md bg-stone-900 px-2.5 py-1.5 text-xs text-white shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
'z-50 overflow-hidden rounded-md bg-foreground px-2.5 py-1.5 text-xs text-background shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -33,6 +33,19 @@ function appendTokens(existing: string | undefined, refs: string): string {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// removeTokens strips space/word-delimited tokens (a "A,B" ref string) from a
|
||||||
|
// text field — the inverse of appendTokens. Used when a picked reference is
|
||||||
|
// REMOVED so an in-field award (DDFM finds "D72" in the note) stops matching it;
|
||||||
|
// otherwise the ref is re-derived from the field and reappears on reopen.
|
||||||
|
function removeTokens(existing: string | undefined, refs: string): string {
|
||||||
|
let out = existing ?? '';
|
||||||
|
for (const tok of refs.split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||||
|
const re = new RegExp(`\\b${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'ig');
|
||||||
|
out = out.replace(re, ' ');
|
||||||
|
}
|
||||||
|
return out.replace(/\s{2,}/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
// MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
|
// MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
|
||||||
// the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
|
// the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
|
||||||
// The award engine honours these regardless of how the award matches, so a
|
// The award engine honours these regardless of how the award matches, so a
|
||||||
@@ -47,8 +60,24 @@ const MANUAL_REFS_KEY = 'APP_OPSLOG_AWARDREFS';
|
|||||||
// data lives in its conventional place and exports correctly. Awards that match
|
// data lives in its conventional place and exports correctly. Awards that match
|
||||||
// a free-text field by description/pattern (address/qth/name/custom) rely solely
|
// a free-text field by description/pattern (address/qth/name/custom) rely solely
|
||||||
// on the override — we don't pollute the text field with a code.
|
// on the override — we don't pollute the text field with a code.
|
||||||
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) {
|
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>, prevAwardRefs?: string) {
|
||||||
const byCode = parseAwardRefs(awardRefs);
|
const byCode = parseAwardRefs(awardRefs);
|
||||||
|
// References the operator REMOVED since the editor opened: for in-field awards
|
||||||
|
// (note/comment) strip the token from the field too, so a deleted DDFM/etc.
|
||||||
|
// ref doesn't get re-derived from the leftover token and reappear on reopen.
|
||||||
|
if (prevAwardRefs != null) {
|
||||||
|
const prev = parseAwardRefs(prevAwardRefs);
|
||||||
|
for (const [code, prevRef] of Object.entries(prev)) {
|
||||||
|
const field = fieldOf[code] || code.toLowerCase();
|
||||||
|
if (field !== 'note' && field !== 'notes' && field !== 'comment') continue;
|
||||||
|
const now = new Set((byCode[code] ?? '').split(',').map((s) => s.trim().toUpperCase()).filter(Boolean));
|
||||||
|
const removed = prevRef.split(',').map((s) => s.trim()).filter(Boolean).filter((r) => !now.has(r.toUpperCase()));
|
||||||
|
if (removed.length === 0) continue;
|
||||||
|
const rm = removed.join(',');
|
||||||
|
if (field === 'comment') payload.comment = removeTokens(payload.comment, rm);
|
||||||
|
else payload.notes = removeTokens(payload.notes, rm);
|
||||||
|
}
|
||||||
|
}
|
||||||
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
|
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
|
||||||
const overrides: string[] = [];
|
const overrides: string[] = [];
|
||||||
for (const [code, ref] of Object.entries(byCode)) {
|
for (const [code, ref] of Object.entries(byCode)) {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { themeQuartz } from 'ag-grid-community';
|
||||||
|
|
||||||
|
// Shared AG-Grid theme for every logbook table (Recent QSOs, Worked-before,
|
||||||
|
// Cluster, Net, QSL Manager). Colours are wired to the app's semantic CSS
|
||||||
|
// variables (see style.css) instead of hardcoded hex, so the grids follow the
|
||||||
|
// active theme (light-warm / dark-warm / graphite / high-contrast) at runtime —
|
||||||
|
// flipping <html data-theme> re-resolves these var() references with no
|
||||||
|
// re-render. Keep this the single source of truth; per-grid tweaks (e.g. row
|
||||||
|
// height) chain a `.withParams({...})` on top.
|
||||||
|
export const hamlogGridTheme = themeQuartz.withParams({
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 12.5,
|
||||||
|
backgroundColor: 'var(--card)',
|
||||||
|
foregroundColor: 'var(--foreground)',
|
||||||
|
headerBackgroundColor: 'var(--muted)',
|
||||||
|
headerTextColor: 'var(--muted-foreground)',
|
||||||
|
headerFontWeight: 600,
|
||||||
|
oddRowBackgroundColor: 'color-mix(in srgb, var(--muted) 40%, var(--card))',
|
||||||
|
rowHoverColor: 'color-mix(in srgb, var(--accent) 55%, var(--card))',
|
||||||
|
selectedRowBackgroundColor: 'var(--accent)',
|
||||||
|
borderColor: 'var(--border)',
|
||||||
|
rowBorder: { color: 'var(--border)', width: 1 },
|
||||||
|
columnBorder: { color: 'var(--border)', width: 1 },
|
||||||
|
cellHorizontalPadding: 10,
|
||||||
|
rowHeight: 30,
|
||||||
|
headerHeight: 32,
|
||||||
|
spacing: 4,
|
||||||
|
accentColor: 'var(--primary)',
|
||||||
|
iconSize: 12,
|
||||||
|
});
|
||||||
+42
-18
@@ -20,7 +20,8 @@ const en: Dict = {
|
|||||||
'view.refresh': 'Refresh', 'view.clearFilters': 'Clear filters',
|
'view.refresh': 'Refresh', 'view.clearFilters': 'Clear filters',
|
||||||
'tools.qslManager': 'QSL Manager…', 'tools.qslDesigner': 'QSL Card Designer…',
|
'tools.qslManager': 'QSL Manager…', 'tools.qslDesigner': 'QSL Card Designer…',
|
||||||
'tools.winkeyer': 'WinKeyer CW keyer', 'tools.dvk': 'Digital Voice Keyer', 'tools.cwDecoder': 'CW decoder (RX audio)',
|
'tools.winkeyer': 'WinKeyer CW keyer', 'tools.dvk': 'Digital Voice Keyer', 'tools.cwDecoder': 'CW decoder (RX audio)',
|
||||||
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…',
|
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode',
|
||||||
|
'alert.tuneHint': 'Click to tune the rig to this spot (freq + mode) and fill the call', 'alert.dismiss': 'Dismiss', 'alert.pending': '{n} recent spot alert(s) — click to view', 'alert.noneShort': 'No recent alerts', 'alert.recent': 'Recent alerts', 'alert.clear': 'Clear',
|
||||||
'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…',
|
'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…',
|
||||||
// Duplicates modal
|
// Duplicates modal
|
||||||
'dup.title': 'Find duplicates', 'dup.scanning': 'Scanning the log…', 'dup.none': 'No duplicates found. 🎉',
|
'dup.title': 'Find duplicates', 'dup.scanning': 'Scanning the log…', 'dup.none': 'No duplicates found. 🎉',
|
||||||
@@ -43,6 +44,9 @@ const en: Dict = {
|
|||||||
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
||||||
'lang.english': 'English', 'lang.french': 'Français',
|
'lang.english': 'English', 'lang.french': 'Français',
|
||||||
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
||||||
|
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
|
||||||
|
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.dark-warm': 'Warm dark',
|
||||||
|
'theme.dark-graphite': 'Graphite dark', 'theme.high-contrast': 'High contrast',
|
||||||
// Settings navigation (sidebar groups + section names)
|
// Settings navigation (sidebar groups + section names)
|
||||||
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists',
|
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists',
|
||||||
'sec.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions',
|
'sec.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions',
|
||||||
@@ -122,12 +126,14 @@ const en: Dict = {
|
|||||||
// Section hints (hardware/software panel headers)
|
// Section hints (hardware/software panel headers)
|
||||||
'autostart.hint': 'Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile.',
|
'autostart.hint': 'Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile.',
|
||||||
'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).",
|
'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).",
|
||||||
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).',
|
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||||
'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
||||||
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
||||||
'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
||||||
// CAT panel body
|
// CAT panel body
|
||||||
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
||||||
|
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
|
||||||
|
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
|
||||||
'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)",
|
'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)",
|
||||||
'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.',
|
'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.',
|
||||||
'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)",
|
'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)",
|
||||||
@@ -146,6 +152,8 @@ const en: Dict = {
|
|||||||
// Database panel
|
// Database panel
|
||||||
'db.optSqlite': 'SQLite — local file (solo)', 'db.optMysql': 'MySQL — shared server (multi-operator)', 'db.profileHint': 'This is the logbook for the active profile. Different profiles can point at different databases — switching profile switches the logbook.',
|
'db.optSqlite': 'SQLite — local file (solo)', 'db.optMysql': 'MySQL — shared server (multi-operator)', 'db.profileHint': 'This is the logbook for the active profile. Different profiles can point at different databases — switching profile switches the logbook.',
|
||||||
'db.saveSwitch': 'Save & switch logbook', 'db.switchedMysql': 'Logbook switched to MySQL ✓', 'db.switchedSqlite': 'Logbook switched to local SQLite ✓',
|
'db.saveSwitch': 'Save & switch logbook', 'db.switchedMysql': 'Logbook switched to MySQL ✓', 'db.switchedSqlite': 'Logbook switched to local SQLite ✓',
|
||||||
|
'db.backend': 'Backend', 'db.configLocal': 'settings stay in the local SQLite file', 'db.connectUse': 'Connect & use',
|
||||||
|
'db.savedRestart': 'Saved. Restart OpsLog to open this logbook:', 'db.restartNow': 'Restart OpsLog', 'db.restartHint': '(reopens automatically)',
|
||||||
'db.current': 'Current database', 'db.customLoc': '(custom location)', 'db.default': '(default)', 'db.defaultLabel': 'Default:',
|
'db.current': 'Current database', 'db.customLoc': '(custom location)', 'db.default': '(default)', 'db.defaultLabel': 'Default:',
|
||||||
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
|
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
|
||||||
'db.host': 'Host', 'db.port': 'Port', 'db.database': 'Database', 'db.user': 'User', 'db.testCreate': 'Test & create database', 'db.testing': 'Testing…', 'db.connectedReady': 'Connected — database ready ✓', 'db.failed': 'Failed: ',
|
'db.host': 'Host', 'db.port': 'Port', 'db.database': 'Database', 'db.user': 'User', 'db.testCreate': 'Test & create database', 'db.testing': 'Testing…', 'db.connectedReady': 'Connected — database ready ✓', 'db.failed': 'Failed: ',
|
||||||
@@ -168,6 +176,7 @@ const en: Dict = {
|
|||||||
'bmp.statusNew': 'NEW DXCC (entity never worked)', 'bmp.statusNewBand': 'NEW BAND (entity not worked on this band)', 'bmp.statusNewSlot': 'NEW SLOT (mode not worked on this band)',
|
'bmp.statusNew': 'NEW DXCC (entity never worked)', 'bmp.statusNewBand': 'NEW BAND (entity not worked on this band)', 'bmp.statusNewSlot': 'NEW SLOT (mode not worked on this band)',
|
||||||
'bmp.statusWorked': 'Worked (this band + mode already in log)', 'bmp.statusUnresolved': 'Entity not resolved', 'bmp.bandMap': 'Band map', 'bmp.notConfigured': 'Not configured for {band}.',
|
'bmp.statusWorked': 'Worked (this band + mode already in log)', 'bmp.statusUnresolved': 'Entity not resolved', 'bmp.bandMap': 'Band map', 'bmp.notConfigured': 'Not configured for {band}.',
|
||||||
'bmp.map': 'Map', 'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map',
|
'bmp.map': 'Map', 'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map',
|
||||||
|
'bmp.bandsLabel': 'Bands:', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Hide FTx', 'bmp.hideFtTitle': 'Hide all digital (FT8/FT4/JS8/…) spots on every band map', 'bmp.fitBand': 'Fit to band', 'bmp.fitTitle': 'Size each band map to show the whole band edge-to-edge',
|
||||||
'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.legendWorked': 'Worked', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)',
|
'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.legendWorked': 'Worked', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)',
|
||||||
'frm.welcome': 'Welcome to OpsLog', 'frm.intro': 'Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).',
|
'frm.welcome': 'Welcome to OpsLog', 'frm.intro': 'Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).',
|
||||||
'frm.callsign': 'Callsign', 'frm.locator': 'Locator', 'frm.operator': 'Operator', 'frm.operatorPh': 'same as callsign', 'frm.owner': 'Owner', 'frm.ownerPh': 'station owner callsign', 'frm.name': 'Name', 'frm.namePh': 'your first name',
|
'frm.callsign': 'Callsign', 'frm.locator': 'Locator', 'frm.operator': 'Operator', 'frm.operatorPh': 'same as callsign', 'frm.owner': 'Owner', 'frm.ownerPh': 'station owner callsign', 'frm.name': 'Name', 'frm.namePh': 'your first name',
|
||||||
@@ -177,29 +186,32 @@ const en: Dict = {
|
|||||||
'adx.introPart1': 'Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it, or type a custom/vendor tag (e.g. ', 'adx.introPart2': '). Stored losslessly and exported in the ', 'adx.fullMode': 'full', 'adx.introPart3': ' ADIF mode.', 'adx.addFieldPh': 'Add ADIF field…', 'adx.showDeprecated': 'Show deprecated', 'adx.noExtra': 'No extra ADIF fields. Use the picker above to add one.', 'adx.deprecated': 'deprecated', 'adx.intl': 'intl', 'adx.nonStandard': 'non-standard', 'adx.removeField': 'Remove field',
|
'adx.introPart1': 'Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it, or type a custom/vendor tag (e.g. ', 'adx.introPart2': '). Stored losslessly and exported in the ', 'adx.fullMode': 'full', 'adx.introPart3': ' ADIF mode.', 'adx.addFieldPh': 'Add ADIF field…', 'adx.showDeprecated': 'Show deprecated', 'adx.noExtra': 'No extra ADIF fields. Use the picker above to add one.', 'adx.deprecated': 'deprecated', 'adx.intl': 'intl', 'adx.nonStandard': 'non-standard', 'adx.removeField': 'Remove field',
|
||||||
// Hardware panels (winkeyer / dvk / antgenius / flex / icom)
|
// Hardware panels (winkeyer / dvk / antgenius / flex / icom)
|
||||||
'wkp.sending': 'Sending…', 'wkp.connectedV': 'Connected (v{version})', 'wkp.disconnected': 'Disconnected', 'wkp.comPort': 'COM port', 'wkp.noPorts': 'No ports', 'wkp.refreshPorts': 'Refresh ports', 'wkp.connect': 'Connect', 'wkp.disconnect': 'Disconnect', 'wkp.hide': 'Hide / disable WinKeyer',
|
'wkp.sending': 'Sending…', 'wkp.connectedV': 'Connected (v{version})', 'wkp.disconnected': 'Disconnected', 'wkp.comPort': 'COM port', 'wkp.noPorts': 'No ports', 'wkp.refreshPorts': 'Refresh ports', 'wkp.connect': 'Connect', 'wkp.disconnect': 'Disconnect', 'wkp.hide': 'Hide / disable WinKeyer',
|
||||||
|
'wkp.sourceHint': 'CW output: WK = WinKeyer hardware · CI-V = the Icom rig’s own keyer (over CAT, no extra hardware)', 'wkp.civReady': 'Icom CI-V ready', 'wkp.civOffline': 'Icom not connected (Settings → CAT)',
|
||||||
'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop',
|
'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop',
|
||||||
|
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL",
|
||||||
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
|
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
|
||||||
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1–F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message',
|
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1–F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message',
|
||||||
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.',
|
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.',
|
||||||
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
|
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
|
||||||
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER',
|
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER',
|
||||||
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter',
|
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope −50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?',
|
||||||
'rst.clickToFill': 'Click to set RST tx from the signal',
|
'rst.clickToFill': 'Click to set RST tx from the signal',
|
||||||
|
'qrz.openTitle': 'Open {call} on QRZ.com',
|
||||||
// Misc panels/modals (alerts / send-spot / net / udp / filter / details)
|
// Misc panels/modals (alerts / send-spot / net / udp / filter / details)
|
||||||
'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close',
|
'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close',
|
||||||
'spm.callRequired': 'Callsign required', 'spm.freqRequired': 'Frequency (kHz) required', 'spm.title': 'Send DX Spot', 'spm.callsign': 'Callsign', 'spm.callPh': 'DX call', 'spm.frequency': 'Frequency (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'e.g. CW · TNX QSO', 'spm.latestQsos': 'Latest QSOs', 'spm.spotSent': 'Spot sent ✓', 'spm.masterCluster': 'Master cluster', 'spm.cancel': 'Cancel', 'spm.sending': 'Sending…', 'spm.sendSpot': 'Send spot',
|
'spm.callRequired': 'Callsign required', 'spm.freqRequired': 'Frequency (kHz) required', 'spm.title': 'Send DX Spot', 'spm.callsign': 'Callsign', 'spm.callPh': 'DX call', 'spm.frequency': 'Frequency (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'e.g. CW · TNX QSO', 'spm.latestQsos': 'Latest QSOs', 'spm.spotSent': 'Spot sent ✓', 'spm.masterCluster': 'Master cluster', 'spm.cancel': 'Cancel', 'spm.sending': 'Sending…', 'spm.sendSpot': 'Send spot',
|
||||||
'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'double-click → edit all fields · "Log & end" to save', 'ncp.logEndSelected': 'Log & end selected', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
|
'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.wbResize': 'Drag to resize', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'double-click → edit all fields · "Log & end" to save', 'ncp.logEndSelected': 'Log & end selected', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
|
||||||
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcDbLabel': 'DB updated → notify other apps', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save',
|
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as <PST><FREQUENCY> whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save',
|
||||||
'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'QSL sent', 'fltb.fQslRcvd': 'QSL rcvd', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent', 'fltb.fLotwRcvd': 'LoTW rcvd', 'fltb.fEqslSent': 'eQSL sent', 'fltb.fEqslRcvd': 'eQSL rcvd', 'fltb.fQrzUpload': 'QRZ upload status', 'fltb.fClublogUpload': 'ClubLog upload status', 'fltb.fHrdlogUpload': 'HRDLog upload status', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'My callsign', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close',
|
'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'QSL sent', 'fltb.fQslRcvd': 'QSL rcvd', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent', 'fltb.fLotwRcvd': 'LoTW rcvd', 'fltb.fEqslSent': 'eQSL sent', 'fltb.fEqslRcvd': 'eQSL rcvd', 'fltb.fQrzUpload': 'QRZ upload status', 'fltb.fClublogUpload': 'ClubLog upload status', 'fltb.fHrdlogUpload': 'HRDLog upload status', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'My callsign', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close',
|
||||||
'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ zone', 'detp.ituZone': 'ITU zone', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via', 'detp.detected': 'Detected — this contact will count for:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email',
|
'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ zone', 'detp.ituZone': 'ITU zone', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via', 'detp.detected': 'Detected — this contact will count for:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email',
|
||||||
// Awards (ref picker / ref selector / awards panel / award editor)
|
// Awards (ref picker / ref selector / awards panel / award editor)
|
||||||
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
|
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
|
||||||
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
|
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
|
||||||
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
|
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
|
||||||
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.allowMultiple': 'Allow multiple references on a single QSO', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Additional searches', 'awed.orAlsoMatch': '— also match the reference if any of these hit', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.grantCodes': 'Grant codes', 'awed.exportCreditGranted': 'Export award in ADIF credit_granted field', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
|
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.allowMultiple': 'Allow multiple references on a single QSO', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Additional searches', 'awed.orAlsoMatch': '— also match the reference if any of these hit', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.grantCodes': 'Grant codes', 'awed.exportCreditGranted': 'Export award in ADIF credit_granted field', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
|
||||||
// QSO modals (context menu / bulk edit / QSL manager / QSO edit)
|
// QSO modals (context menu / bulk edit / QSL manager / QSO edit)
|
||||||
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
|
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
|
||||||
'bulk.fLotwSent': 'LoTW sent', 'bulk.fLotwRcvd': 'LoTW received', 'bulk.fEqslSent': 'eQSL sent', 'bulk.fEqslRcvd': 'eQSL received', 'bulk.fQslSent': 'Paper QSL sent', 'bulk.fQslRcvd': 'Paper QSL received', 'bulk.fQrzUpload': 'QRZ.com upload', 'bulk.fClublogUpload': 'Club Log upload', 'bulk.fHrdlogUpload': 'HRDLog upload', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Station callsign', 'bulk.fOperator': 'Operator', 'bulk.fMyGrid': 'My grid', 'bulk.fMyAntenna': 'My antenna', 'bulk.fMyRig': 'My rig', 'bulk.fMyStreet': 'My street', 'bulk.fMyCity': 'My city', 'bulk.fMyPostal': 'My postal code', 'bulk.fMyCountry': 'My country', 'bulk.fMyState': 'My state', 'bulk.fMyCounty': 'My county', 'bulk.fMyIota': 'My IOTA', 'bulk.fMySota': 'My SOTA ref', 'bulk.fMyPota': 'My POTA ref', 'bulk.fMyWwff': 'My WWFF ref', 'bulk.fMySig': 'My SIG', 'bulk.fMySigInfo': 'My SIG info', 'bulk.fComment': 'Comment', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Rig (contacted)', 'bulk.fAnt': 'Antenna (contacted)', 'bulk.statusY': 'Y — Yes / uploaded', 'bulk.statusN': 'N — No', 'bulk.statusR': 'R — Requested', 'bulk.statusI': 'I — Ignore', 'bulk.statusBlank': '(blank — clear)', 'bulk.groupQsl': 'QSL / upload', 'bulk.groupMyStation': 'My station', 'bulk.groupMisc': 'Misc', 'bulk.title': 'Bulk edit field', 'bulk.desc': 'Set one field on the {n} selected QSO(s). This overwrites the current value — there is no undo.', 'bulk.fieldLabel': 'Field', 'bulk.valueLabel': 'Value', 'bulk.clearPlaceholder': 'leave empty to clear the field', 'bulk.willSet': 'Will set', 'bulk.blank': '(blank)', 'bulk.onQsos': 'on {n} QSO(s).', 'bulk.cancel': 'Cancel', 'bulk.applyTo': 'Apply to {n}',
|
'bulk.fLotwSent': 'LoTW sent', 'bulk.fLotwRcvd': 'LoTW received', 'bulk.fEqslSent': 'eQSL sent', 'bulk.fEqslRcvd': 'eQSL received', 'bulk.fQslSent': 'Paper QSL sent', 'bulk.fQslRcvd': 'Paper QSL received', 'bulk.fQrzUpload': 'QRZ.com upload', 'bulk.fClublogUpload': 'Club Log upload', 'bulk.fHrdlogUpload': 'HRDLog upload', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Station callsign', 'bulk.fOperator': 'Operator', 'bulk.fMyGrid': 'My grid', 'bulk.fMyAntenna': 'My antenna', 'bulk.fMyRig': 'My rig', 'bulk.fMyStreet': 'My street', 'bulk.fMyCity': 'My city', 'bulk.fMyPostal': 'My postal code', 'bulk.fMyCountry': 'My country', 'bulk.fMyState': 'My state', 'bulk.fMyCounty': 'My county', 'bulk.fMyIota': 'My IOTA', 'bulk.fMySota': 'My SOTA ref', 'bulk.fMyPota': 'My POTA ref', 'bulk.fMyWwff': 'My WWFF ref', 'bulk.fMySig': 'My SIG', 'bulk.fMySigInfo': 'My SIG info', 'bulk.fContestId': 'Contest ID', 'bulk.fSrxString': 'Serial rcvd (exchange)', 'bulk.fStxString': 'Serial sent (exchange)', 'bulk.fArrlSect': 'ARRL section', 'bulk.fPrecedence': 'Precedence', 'bulk.fClass': 'Class', 'bulk.fPropMode': 'Propagation mode', 'bulk.fSatName': 'Satellite name', 'bulk.fSatMode': 'Satellite mode', 'bulk.fPotaRef': 'POTA ref', 'bulk.fSotaRef': 'SOTA ref', 'bulk.fWwffRef': 'WWFF ref', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'SIG info', 'bulk.fComment': 'Comment', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Rig (contacted)', 'bulk.fAnt': 'Antenna (contacted)', 'bulk.statusY': 'Y — Yes / uploaded', 'bulk.statusN': 'N — No', 'bulk.statusR': 'R — Requested', 'bulk.statusI': 'I — Ignore', 'bulk.statusBlank': '(blank — clear)', 'bulk.groupQsl': 'QSL / upload', 'bulk.groupMyStation': 'My station', 'bulk.groupContacted': 'Contacted station', 'bulk.groupContest': 'Contest', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Misc', 'bulk.title': 'Bulk edit field', 'bulk.desc': 'Set one field on the {n} selected QSO(s). This overwrites the current value — there is no undo.', 'bulk.fieldLabel': 'Field', 'bulk.valueLabel': 'Value', 'bulk.clearPlaceholder': 'leave empty to clear the field', 'bulk.willSet': 'Will set', 'bulk.blank': '(blank)', 'bulk.onQsos': 'on {n} QSO(s).', 'bulk.cancel': 'Cancel', 'bulk.applyTo': 'Apply to {n}',
|
||||||
'qslm.leave': '— leave —', 'qslm.yes': 'Yes', 'qslm.no': 'No', 'qslm.requested': 'Requested', 'qslm.ignore': 'Ignore', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Electronic', 'qslm.svcPota': 'POTA hunter log', 'qslm.svcPaper': 'Paper QSL', 'qslm.sentRequested': 'Requested', 'qslm.sentNo': 'No', 'qslm.sentQueued': 'Queued', 'qslm.sentYes': 'Yes (already sent)', 'qslm.sentInvalid': 'Invalid', 'qslm.sentBlank': '— blank —', 'qslm.qsoUpdated': '{n} QSO updated.', 'qslm.service': 'Service', 'qslm.callsign': 'Callsign', 'qslm.callsignScopeTitle': "Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)", 'qslm.syncHunterLog': 'Sync hunter log', 'qslm.onlyMyCallTitle': "Only sync hunts made under your active profile's callsign — skip QSOs you made under another call (e.g. XV9Q, NQ2H) that aren't in this logbook", 'qslm.onlyMyCall': 'Only my profile callsign', 'qslm.addMissingTitle': "Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)", 'qslm.addMissing': 'Add not-found QSOs to my log', 'qslm.potaToken': 'Token in Settings → External services → POTA.', 'qslm.callsignPlaceholder': 'e.g. DL1ABC', 'qslm.search': 'Search', 'qslm.paperHint': 'Find a callsign, then set QSL sent/received + via + date on the selection.', 'qslm.sentStatus': 'Sent status', 'qslm.selectRequired': 'Select required', 'qslm.potaSummaryShort': '{updated} updated · {added} added · {already} already · {unmatched} unmatched', 'qslm.potaOtherCall': ' · {n} other call', 'qslm.paperCount': '{total} QSO · {selected} selected', 'qslm.filter': 'Filter', 'qslm.filterAll': 'All', 'qslm.filterNew': 'New (any)', 'qslm.filterNewDxcc': 'New DXCC', 'qslm.filterNewBand': 'New band', 'qslm.filterNewSlot': 'New slot', 'qslm.results': 'Results', 'qslm.log': 'Log', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} found · {selected} selected', 'qslm.paperEmpty': 'Search a callsign to list its QSOs, then set QSL status below.', 'qslm.potaEmpty': 'Click "Sync hunter log" to fetch your pota.app log and stamp park references.', 'qslm.potaSyncing': 'Syncing with pota.app…', 'qslm.potaSummary': '{updated} QSO updated · {added} added to log · {already} already tagged · {unmatched} unmatched (of {fetched} hunter-log entries).', 'qslm.potaSkipped': ' {n} hunt(s) made under another callsign were skipped', 'qslm.potaKeptOnly': ' (kept only {call})', 'qslm.potaRescan': 'Rescan the POTA award to count the new references.', 'qslm.thActivator': 'Activator', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Band', 'qslm.thPark': 'Park', 'qslm.thWhyUnmatched': 'Why unmatched', 'qslm.openToFix': 'Open this QSO to fix it', 'qslm.starting': 'starting…', 'qslm.working': 'working…', 'qslm.noNewConf': 'No new confirmations.', 'qslm.noConfMatch': 'No confirmations match this filter.', 'qslm.thCallsign': 'Callsign', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Country', 'qslm.thNew': 'New?', 'qslm.newDxcc': 'NEW DXCC', 'qslm.newBand': 'NEW BAND', 'qslm.newSlot': 'NEW SLOT', 'qslm.uploadEmpty': 'Pick a service + sent status, then "Select required".', 'qslm.qslReceived': 'QSL received', 'qslm.qslRcvdDateTitle': 'QSL received date', 'qslm.qslSent': 'QSL sent', 'qslm.qslSentDateTitle': 'QSL sent date', 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'e.g. paid 3€', 'qslm.comment': 'Comment', 'qslm.commentPlaceholder': 'comment', 'qslm.applyToSelected': 'Apply to {n} selected', 'qslm.downloadTitle': 'Fetch confirmations from the service and update received status', 'qslm.downloadConf': 'Download confirmations', 'qslm.downloadRangeTitle': 'How far back to download', 'qslm.sinceLast': 'Since last download', 'qslm.sinceDate': 'Since date…', 'qslm.sinceAll': 'All', 'qslm.sinceDateTitleQrz': 'QRZ: filters by QSO date (no server-side received-date filter)', 'qslm.sinceDateTitleLotw': 'LoTW: confirmations received since this date', 'qslm.addNotFoundTitle': "Insert confirmed QSOs that aren't in your log yet", 'qslm.addNotFound': 'Add not-found', 'qslm.uploadTo': 'Upload {n} to {service}',
|
'qslm.leave': '— leave —', 'qslm.yes': 'Yes', 'qslm.no': 'No', 'qslm.requested': 'Requested', 'qslm.ignore': 'Ignore', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Electronic', 'qslm.svcPota': 'POTA hunter log', 'qslm.svcPaper': 'Paper QSL', 'qslm.sentRequested': 'Requested', 'qslm.sentNo': 'No', 'qslm.sentQueued': 'Queued', 'qslm.sentYes': 'Yes (already sent)', 'qslm.sentInvalid': 'Invalid', 'qslm.sentBlank': '— blank —', 'qslm.qsoUpdated': '{n} QSO updated.', 'qslm.service': 'Service', 'qslm.callsign': 'Callsign', 'qslm.callsignScopeTitle': "Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)", 'qslm.syncHunterLog': 'Sync hunter log', 'qslm.onlyMyCallTitle': "Only sync hunts made under your active profile's callsign — skip QSOs you made under another call (e.g. XV9Q, NQ2H) that aren't in this logbook", 'qslm.onlyMyCall': 'Only my profile callsign', 'qslm.addMissingTitle': "Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)", 'qslm.addMissing': 'Add not-found QSOs to my log', 'qslm.potaToken': 'Token in Settings → External services → POTA.', 'qslm.callsignPlaceholder': 'e.g. DL1ABC', 'qslm.search': 'Search', 'qslm.paperHint': 'Find a callsign, then set QSL sent/received + via + date on the selection.', 'qslm.sentStatus': 'Sent status', 'qslm.selectRequired': 'Select required', 'qslm.potaSummaryShort': '{updated} updated · {added} added · {already} already · {unmatched} unmatched', 'qslm.potaOtherCall': ' · {n} other call', 'qslm.paperCount': '{total} QSO · {selected} selected', 'qslm.filter': 'Filter', 'qslm.filterAll': 'All', 'qslm.filterNew': 'New (any)', 'qslm.filterNewDxcc': 'New DXCC', 'qslm.filterNewBand': 'New band', 'qslm.filterNewSlot': 'New slot', 'qslm.results': 'Results', 'qslm.log': 'Log', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} found · {selected} selected', 'qslm.paperEmpty': 'Search a callsign to list its QSOs, then set QSL status below.', 'qslm.potaEmpty': 'Click "Sync hunter log" to fetch your pota.app log and stamp park references.', 'qslm.potaSyncing': 'Syncing with pota.app…', 'qslm.potaSummary': '{updated} QSO updated · {added} added to log · {already} already tagged · {unmatched} unmatched (of {fetched} hunter-log entries).', 'qslm.potaSkipped': ' {n} hunt(s) made under another callsign were skipped', 'qslm.potaKeptOnly': ' (kept only {call})', 'qslm.potaRescan': 'Rescan the POTA award to count the new references.', 'qslm.thActivator': 'Activator', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Band', 'qslm.thPark': 'Park', 'qslm.thWhyUnmatched': 'Why unmatched', 'qslm.openToFix': 'Open this QSO to fix it', 'qslm.starting': 'starting…', 'qslm.working': 'working…', 'qslm.noNewConf': 'No new confirmations.', 'qslm.noConfMatch': 'No confirmations match this filter.', 'qslm.thCallsign': 'Callsign', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Country', 'qslm.thNew': 'New?', 'qslm.newDxcc': 'NEW DXCC', 'qslm.newBand': 'NEW BAND', 'qslm.newSlot': 'NEW SLOT', 'qslm.uploadEmpty': 'Pick a service + sent status, then "Select required".', 'qslm.qslReceived': 'QSL received', 'qslm.qslRcvdDateTitle': 'QSL received date', 'qslm.qslSent': 'QSL sent', 'qslm.qslSentDateTitle': 'QSL sent date', 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'e.g. paid 3€', 'qslm.comment': 'Comment', 'qslm.commentPlaceholder': 'comment', 'qslm.applyToSelected': 'Apply to {n} selected', 'qslm.downloadTitle': 'Fetch confirmations from the service and update received status', 'qslm.downloadConf': 'Download confirmations', 'qslm.downloadRangeTitle': 'How far back to download', 'qslm.sinceLast': 'Since last download', 'qslm.sinceDate': 'Since date…', 'qslm.sinceAll': 'All', 'qslm.sinceDateTitleQrz': 'QRZ: filters by QSO date (no server-side received-date filter)', 'qslm.sinceDateTitleLotw': 'LoTW: confirmations received since this date', 'qslm.addNotFoundTitle': "Insert confirmed QSOs that aren't in your log yet", 'qslm.addNotFound': 'Add not-found', 'qslm.uploadTo': 'Upload {n} to {service}',
|
||||||
'qedit.qslDash': '—', 'qedit.qslYes': 'Yes', 'qedit.qslNo': 'No', 'qedit.qslRequested': 'Requested', 'qedit.qslIgnore': 'Ignore', 'qedit.statusModified': 'Modified', 'qedit.confQslPaper': 'QSL (paper)', 'qedit.callsignRequired': 'Callsign required', 'qedit.lookupError': 'Lookup: {msg}', 'qedit.title': 'Edit QSO', 'qedit.editFieldsFor': 'Edit fields for QSO #{id}', 'qedit.tabQsoInfo': 'QSO Info', 'qedit.tabContact': "Contact's details", 'qedit.tabAwards': 'Award Refs', 'qedit.tabQsl': 'QSL Info', 'qedit.tabContest': 'Contest', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'My Station', 'qedit.tabMoreAdif': 'More ADIF', 'qedit.tabAdifFields': 'ADIF fields', 'qedit.callsign': 'Callsign', 'qedit.fetchTitle': 'Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…', 'qedit.fetch': 'Fetch', 'qedit.name': 'Name', 'qedit.band': 'Band', 'qedit.rxBand': 'RX Band', 'qedit.mode': 'Mode', 'qedit.country': 'Country', 'qedit.dxccTitle': 'DXCC entity # — set automatically from Country', 'qedit.txFreq': 'TX Freq', 'qedit.rxFreq': 'RX Freq', 'qedit.qsoStart': 'QSO Start (UTC)', 'qedit.qsoEnd': 'QSO End (UTC)', 'qedit.grid': 'Grid', 'qedit.comment': 'Comment', 'qedit.note': 'Note', 'qedit.county': 'County', 'qedit.state': 'State', 'qedit.continent': 'Continent', 'qedit.address': 'Address', 'qedit.email': 'E-mail address', 'qedit.qslMsg': 'QSL Msg', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Computed (automatic)', 'qedit.computedHint': "Derived from this QSO's fields (DXCC, zones, prefix, notes…). Not editable here.", 'qedit.noneYet': 'None yet.', 'qedit.manageConf': 'Manage Confirmation', 'qedit.sent': 'Sent', 'qedit.received': 'Received', 'qedit.dateSent': 'Date sent', 'qedit.dateReceived': 'Date received', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Pick a channel, edit it — the table on the right updates live. Everything is written when you click', 'qedit.saveChanges': 'Save changes', 'qedit.thType': 'Type', 'qedit.contestId': 'Contest ID', 'qedit.rcvdExchange': 'rcvd exchange', 'qedit.sentExchange': 'sent exchange', 'qedit.check': 'Check', 'qedit.precedence': 'Precedence', 'qedit.arrlSection': 'ARRL section', 'qedit.propMode': 'Propagation mode', 'qedit.satName': 'Satellite name', 'qedit.satMode': 'Satellite mode', 'qedit.antAz': 'Antenna AZ (°)', 'qedit.antEl': 'Antenna EL (°)', 'qedit.antPath': 'Antenna path', 'qedit.myStationHint': 'These override the active station profile for this QSO only.', 'qedit.stationCallsign': 'Station callsign', 'qedit.operator': 'Operator', 'qedit.myGrid': 'My grid', 'qedit.gridExt': 'Grid ext', 'qedit.cqZone': 'CQ zone', 'qedit.ituZone': 'ITU zone', 'qedit.sotaRef': 'SOTA ref', 'qedit.potaRef': 'POTA ref', 'qedit.street': 'Street', 'qedit.city': 'City', 'qedit.postal': 'Postal', 'qedit.rig': 'Rig', 'qedit.antenna': 'Antenna', 'qedit.specialActivity': 'Special activity', 'qedit.sigInfo': 'SIG info', 'qedit.wwffRef': 'WWFF ref', 'qedit.region': 'Region', 'qedit.powerWeather': 'Power & space weather', 'qedit.rxPower': 'RX power (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'A index', 'qedit.kIndex': 'K index', 'qedit.identityClubs': 'Identity & clubs', 'qedit.contactedOp': 'Contacted op', 'qedit.formerCall': 'Former call (EQ_CALL)', 'qedit.class': 'Class', 'qedit.flagsCredits': 'Flags & credits', 'qedit.qsoComplete': 'QSO complete', 'qedit.qsoRandom': 'QSO random', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Credit granted', 'qedit.creditSubmitted': 'Credit submitted', 'qedit.myStationAdif': 'My station (ADIF)', 'qedit.myName': 'My name', 'qedit.myWwffRef': 'My WWFF ref', 'qedit.myArrlSect': 'My ARRL sect', 'qedit.mySig': 'My SIG', 'qedit.mySigInfo': 'My SIG info', 'qedit.myDarcDok': 'My DARC DOK', 'qedit.myVuccGrids': 'My VUCC grids', 'qedit.delete': 'Delete', 'qedit.cancel': 'Cancel', 'qedit.saving': 'Saving…',
|
'qedit.qslDash': '—', 'qedit.qslYes': 'Yes', 'qedit.qslNo': 'No', 'qedit.qslRequested': 'Requested', 'qedit.qslIgnore': 'Ignore', 'qedit.statusModified': 'Modified', 'qedit.confQslPaper': 'QSL (paper)', 'qedit.callsignRequired': 'Callsign required', 'qedit.lookupError': 'Lookup: {msg}', 'qedit.title': 'Edit QSO', 'qedit.editFieldsFor': 'Edit fields for QSO #{id}', 'qedit.tabQsoInfo': 'QSO Info', 'qedit.tabContact': "Contact's details", 'qedit.tabAwards': 'Award Refs', 'qedit.tabQsl': 'QSL Info', 'qedit.tabContest': 'Contest', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'My Station', 'qedit.tabMoreAdif': 'More ADIF', 'qedit.tabAdifFields': 'ADIF fields', 'qedit.callsign': 'Callsign', 'qedit.fetchTitle': 'Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…', 'qedit.fetch': 'Fetch', 'qedit.name': 'Name', 'qedit.band': 'Band', 'qedit.rxBand': 'RX Band', 'qedit.mode': 'Mode', 'qedit.country': 'Country', 'qedit.dxccTitle': 'DXCC entity # — set automatically from Country', 'qedit.txFreq': 'TX Freq', 'qedit.rxFreq': 'RX Freq', 'qedit.qsoStart': 'QSO Start (UTC)', 'qedit.qsoEnd': 'QSO End (UTC)', 'qedit.grid': 'Grid', 'qedit.comment': 'Comment', 'qedit.note': 'Note', 'qedit.county': 'County', 'qedit.state': 'State', 'qedit.continent': 'Continent', 'qedit.address': 'Address', 'qedit.email': 'E-mail address', 'qedit.qslMsg': 'QSL Msg', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Computed (automatic)', 'qedit.computedHint': "Derived from this QSO's fields (DXCC, zones, prefix, notes…). Not editable here.", 'qedit.noneYet': 'None yet.', 'qedit.manageConf': 'Manage Confirmation', 'qedit.sent': 'Sent', 'qedit.received': 'Received', 'qedit.dateSent': 'Date sent', 'qedit.dateReceived': 'Date received', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Pick a channel, edit it — the table on the right updates live. Everything is written when you click', 'qedit.saveChanges': 'Save changes', 'qedit.thType': 'Type', 'qedit.contestId': 'Contest ID', 'qedit.rcvdExchange': 'rcvd exchange', 'qedit.sentExchange': 'sent exchange', 'qedit.check': 'Check', 'qedit.precedence': 'Precedence', 'qedit.arrlSection': 'ARRL section', 'qedit.propMode': 'Propagation mode', 'qedit.satName': 'Satellite name', 'qedit.satMode': 'Satellite mode', 'qedit.antAz': 'Antenna AZ (°)', 'qedit.antEl': 'Antenna EL (°)', 'qedit.antPath': 'Antenna path', 'qedit.myStationHint': 'These override the active station profile for this QSO only.', 'qedit.stationCallsign': 'Station callsign', 'qedit.operator': 'Operator', 'qedit.myGrid': 'My grid', 'qedit.gridExt': 'Grid ext', 'qedit.cqZone': 'CQ zone', 'qedit.ituZone': 'ITU zone', 'qedit.sotaRef': 'SOTA ref', 'qedit.potaRef': 'POTA ref', 'qedit.street': 'Street', 'qedit.city': 'City', 'qedit.postal': 'Postal', 'qedit.rig': 'Rig', 'qedit.antenna': 'Antenna', 'qedit.specialActivity': 'Special activity', 'qedit.sigInfo': 'SIG info', 'qedit.wwffRef': 'WWFF ref', 'qedit.region': 'Region', 'qedit.powerWeather': 'Power & space weather', 'qedit.rxPower': 'RX power (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'A index', 'qedit.kIndex': 'K index', 'qedit.identityClubs': 'Identity & clubs', 'qedit.contactedOp': 'Contacted op', 'qedit.formerCall': 'Former call (EQ_CALL)', 'qedit.class': 'Class', 'qedit.flagsCredits': 'Flags & credits', 'qedit.qsoComplete': 'QSO complete', 'qedit.qsoRandom': 'QSO random', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Credit granted', 'qedit.creditSubmitted': 'Credit submitted', 'qedit.myStationAdif': 'My station (ADIF)', 'qedit.myName': 'My name', 'qedit.myWwffRef': 'My WWFF ref', 'qedit.myArrlSect': 'My ARRL sect', 'qedit.mySig': 'My SIG', 'qedit.mySigInfo': 'My SIG info', 'qedit.myDarcDok': 'My DARC DOK', 'qedit.myVuccGrids': 'My VUCC grids', 'qedit.delete': 'Delete', 'qedit.cancel': 'Cancel', 'qedit.saving': 'Saving…',
|
||||||
// Grids column headers (recent / worked-before / cluster)
|
// Grids column headers (recent / worked-before / cluster)
|
||||||
@@ -216,7 +228,8 @@ const fr: Dict = {
|
|||||||
'view.refresh': 'Rafraîchir', 'view.clearFilters': 'Effacer les filtres',
|
'view.refresh': 'Rafraîchir', 'view.clearFilters': 'Effacer les filtres',
|
||||||
'tools.qslManager': 'Gestionnaire QSL…', 'tools.qslDesigner': 'Créateur de carte QSL…',
|
'tools.qslManager': 'Gestionnaire QSL…', 'tools.qslDesigner': 'Créateur de carte QSL…',
|
||||||
'tools.winkeyer': 'Manipulateur CW WinKeyer', 'tools.dvk': 'Manipulateur vocal numérique', 'tools.cwDecoder': 'Décodeur CW (audio RX)',
|
'tools.winkeyer': 'Manipulateur CW WinKeyer', 'tools.dvk': 'Manipulateur vocal numérique', 'tools.cwDecoder': 'Décodeur CW (audio RX)',
|
||||||
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…',
|
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest',
|
||||||
|
'alert.tuneHint': 'Cliquer pour accorder la radio sur ce spot (fréq + mode) et remplir l\'indicatif', 'alert.dismiss': 'Fermer', 'alert.pending': '{n} alerte(s) de spot récente(s) — cliquer pour voir', 'alert.noneShort': 'Aucune alerte récente', 'alert.recent': 'Alertes récentes', 'alert.clear': 'Effacer',
|
||||||
'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…',
|
'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…',
|
||||||
'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉',
|
'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉',
|
||||||
'dup.hint': 'Chaque groupe est le même contact enregistré plusieurs fois (indicatif + bande + mode). Le premier (le plus ancien) est laissé décoché ; coche ceux à supprimer.',
|
'dup.hint': 'Chaque groupe est le même contact enregistré plusieurs fois (indicatif + bande + mode). Le premier (le plus ancien) est laissé décoché ; coche ceux à supprimer.',
|
||||||
@@ -235,6 +248,9 @@ const fr: Dict = {
|
|||||||
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
||||||
'lang.english': 'English', 'lang.french': 'Français',
|
'lang.english': 'English', 'lang.french': 'Français',
|
||||||
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
||||||
|
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
|
||||||
|
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.dark-warm': 'Sombre chaud',
|
||||||
|
'theme.dark-graphite': 'Sombre graphite', 'theme.high-contrast': 'Contraste élevé',
|
||||||
'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes',
|
'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes',
|
||||||
'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
|
'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
|
||||||
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes',
|
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes',
|
||||||
@@ -304,11 +320,13 @@ const fr: Dict = {
|
|||||||
'bk.lastRun': 'Dernière exécution :', 'bk.never': 'jamais', 'bk.backupNow': 'Sauvegarder maintenant', 'bk.backingUp': 'Sauvegarde…', 'bk.writtenTo': 'Sauvegarde écrite dans',
|
'bk.lastRun': 'Dernière exécution :', 'bk.never': 'jamais', 'bk.backupNow': 'Sauvegarder maintenant', 'bk.backingUp': 'Sauvegarde…', 'bk.writtenTo': 'Sauvegarde écrite dans',
|
||||||
'autostart.hint': "Lance des programmes externes (WSJT-X, JTAlert, contrôle rotator…) au démarrage d'OpsLog. Un programme déjà lancé n'est pas relancé. Enregistré par profil.",
|
'autostart.hint': "Lance des programmes externes (WSJT-X, JTAlert, contrôle rotator…) au démarrage d'OpsLog. Un programme déjà lancé n'est pas relancé. Enregistré par profil.",
|
||||||
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
|
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
|
||||||
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).",
|
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||||
'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
||||||
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
||||||
'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
||||||
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
||||||
|
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
|
||||||
|
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
||||||
'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)",
|
'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)",
|
||||||
'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.',
|
'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.',
|
||||||
'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)",
|
'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)",
|
||||||
@@ -324,6 +342,8 @@ const fr: Dict = {
|
|||||||
'prof.configId': 'ID de configuration', 'prof.description': 'Description', 'prof.new': 'Nouveau', 'prof.newTitle': 'Créer un nouveau profil vierge', 'prof.dupTitle': 'Cloner le profil sélectionné (garde tous ses champs)', 'prof.setActive': 'Activer', 'prof.setActiveTitle': 'Activer le profil sélectionné — les nouveaux QSO utiliseront ses champs MY_*', 'prof.deleteTitle': 'Supprimer le profil sélectionné', 'prof.cantDeleteLast': 'Impossible de supprimer le dernier profil', 'prof.activeSuffix': ' (actif)', 'prof.viewingNote': 'Tu consultes {name}. Le profil actif est {active} — ses valeurs sont inscrites sur les nouveaux QSO. Clique « Activer » pour basculer.',
|
'prof.configId': 'ID de configuration', 'prof.description': 'Description', 'prof.new': 'Nouveau', 'prof.newTitle': 'Créer un nouveau profil vierge', 'prof.dupTitle': 'Cloner le profil sélectionné (garde tous ses champs)', 'prof.setActive': 'Activer', 'prof.setActiveTitle': 'Activer le profil sélectionné — les nouveaux QSO utiliseront ses champs MY_*', 'prof.deleteTitle': 'Supprimer le profil sélectionné', 'prof.cantDeleteLast': 'Impossible de supprimer le dernier profil', 'prof.activeSuffix': ' (actif)', 'prof.viewingNote': 'Tu consultes {name}. Le profil actif est {active} — ses valeurs sont inscrites sur les nouveaux QSO. Clique « Activer » pour basculer.',
|
||||||
'db.optSqlite': 'SQLite — fichier local (solo)', 'db.optMysql': 'MySQL — serveur partagé (multi-opérateur)', 'db.profileHint': 'Ceci est le journal du profil actif. Des profils différents peuvent pointer vers des bases différentes — changer de profil change le journal.',
|
'db.optSqlite': 'SQLite — fichier local (solo)', 'db.optMysql': 'MySQL — serveur partagé (multi-opérateur)', 'db.profileHint': 'Ceci est le journal du profil actif. Des profils différents peuvent pointer vers des bases différentes — changer de profil change le journal.',
|
||||||
'db.saveSwitch': 'Enregistrer & basculer le journal', 'db.switchedMysql': 'Journal basculé vers MySQL ✓', 'db.switchedSqlite': 'Journal basculé vers SQLite local ✓',
|
'db.saveSwitch': 'Enregistrer & basculer le journal', 'db.switchedMysql': 'Journal basculé vers MySQL ✓', 'db.switchedSqlite': 'Journal basculé vers SQLite local ✓',
|
||||||
|
'db.backend': 'Base de données', 'db.configLocal': 'les réglages restent dans le fichier SQLite local', 'db.connectUse': 'Connecter et utiliser',
|
||||||
|
'db.savedRestart': 'Enregistré. Redémarrez OpsLog pour ouvrir cette base :', 'db.restartNow': 'Redémarrer OpsLog', 'db.restartHint': '(réouverture automatique)',
|
||||||
'db.current': 'Base actuelle', 'db.customLoc': '(emplacement personnalisé)', 'db.default': '(par défaut)', 'db.defaultLabel': 'Par défaut :',
|
'db.current': 'Base actuelle', 'db.customLoc': '(emplacement personnalisé)', 'db.default': '(par défaut)', 'db.defaultLabel': 'Par défaut :',
|
||||||
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
|
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
|
||||||
'db.host': 'Hôte', 'db.port': 'Port', 'db.database': 'Base', 'db.user': 'Utilisateur', 'db.testCreate': 'Tester & créer la base', 'db.testing': 'Test…', 'db.connectedReady': 'Connecté — base prête ✓', 'db.failed': 'Échec : ',
|
'db.host': 'Hôte', 'db.port': 'Port', 'db.database': 'Base', 'db.user': 'Utilisateur', 'db.testCreate': 'Tester & créer la base', 'db.testing': 'Test…', 'db.connectedReady': 'Connecté — base prête ✓', 'db.failed': 'Échec : ',
|
||||||
@@ -344,6 +364,7 @@ const fr: Dict = {
|
|||||||
'bmp.statusNew': 'NOUVEAU DXCC (entité jamais contactée)', 'bmp.statusNewBand': 'NOUVELLE BANDE (entité non contactée sur cette bande)', 'bmp.statusNewSlot': 'NOUVEAU MODE (mode non contacté sur cette bande)',
|
'bmp.statusNew': 'NOUVEAU DXCC (entité jamais contactée)', 'bmp.statusNewBand': 'NOUVELLE BANDE (entité non contactée sur cette bande)', 'bmp.statusNewSlot': 'NOUVEAU MODE (mode non contacté sur cette bande)',
|
||||||
'bmp.statusWorked': 'Contacté (cette bande + mode déjà au log)', 'bmp.statusUnresolved': 'Entité non résolue', 'bmp.bandMap': 'Carte de bande', 'bmp.notConfigured': 'Non configurée pour {band}.',
|
'bmp.statusWorked': 'Contacté (cette bande + mode déjà au log)', 'bmp.statusUnresolved': 'Entité non résolue', 'bmp.bandMap': 'Carte de bande', 'bmp.notConfigured': 'Non configurée pour {band}.',
|
||||||
'bmp.map': 'Carte', 'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande',
|
'bmp.map': 'Carte', 'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande',
|
||||||
|
'bmp.bandsLabel': 'Bandes :', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Masquer FTx', 'bmp.hideFtTitle': 'Masquer tous les spots numériques (FT8/FT4/JS8/…) sur toutes les cartes', 'bmp.fitBand': 'Ajuster à la bande', 'bmp.fitTitle': 'Dimensionner chaque carte pour afficher toute la bande',
|
||||||
'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.legendWorked': 'Contacté', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)',
|
'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.legendWorked': 'Contacté', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)',
|
||||||
'frm.welcome': 'Bienvenue dans OpsLog', 'frm.intro': 'Configure ta station pour commencer à logger. Ces champs sont inscrits sur chaque QSO et peuvent être modifiés plus tard dans Préférences → Informations station (et par profil).',
|
'frm.welcome': 'Bienvenue dans OpsLog', 'frm.intro': 'Configure ta station pour commencer à logger. Ces champs sont inscrits sur chaque QSO et peuvent être modifiés plus tard dans Préférences → Informations station (et par profil).',
|
||||||
'frm.callsign': 'Indicatif', 'frm.locator': 'Locator', 'frm.operator': 'Opérateur', 'frm.operatorPh': "identique à l'indicatif", 'frm.owner': 'Propriétaire', 'frm.ownerPh': 'indicatif du propriétaire de la station', 'frm.name': 'Nom', 'frm.namePh': 'ton prénom',
|
'frm.callsign': 'Indicatif', 'frm.locator': 'Locator', 'frm.operator': 'Opérateur', 'frm.operatorPh': "identique à l'indicatif", 'frm.owner': 'Propriétaire', 'frm.ownerPh': 'indicatif du propriétaire de la station', 'frm.name': 'Nom', 'frm.namePh': 'ton prénom',
|
||||||
@@ -352,26 +373,29 @@ const fr: Dict = {
|
|||||||
'ctp.stopContest': 'Arrêter le contest', 'ctp.startContest': 'Démarrer le contest', 'ctp.activeHint': 'Le formulaire de saisie affiche Env/Reç et un badge DUPE ; les QSO sont marqués avec ce contest.', 'ctp.inactiveHint': 'Choisis un contest, règle la fenêtre, puis Démarrer.', 'ctp.scoreboard': 'Tableau des scores', 'ctp.estimate': 'estimation', 'ctp.qsos': 'QSO', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Dernières 60 min', 'ctp.band': 'Bande',
|
'ctp.stopContest': 'Arrêter le contest', 'ctp.startContest': 'Démarrer le contest', 'ctp.activeHint': 'Le formulaire de saisie affiche Env/Reç et un badge DUPE ; les QSO sont marqués avec ce contest.', 'ctp.inactiveHint': 'Choisis un contest, règle la fenêtre, puis Démarrer.', 'ctp.scoreboard': 'Tableau des scores', 'ctp.estimate': 'estimation', 'ctp.qsos': 'QSO', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Dernières 60 min', 'ctp.band': 'Bande',
|
||||||
'adx.introPart1': 'Tous les champs ADIF 3.1.7 non affichés dans les autres onglets. Choisis un champ à ajouter, ou saisis un tag personnalisé/constructeur (ex. ', 'adx.introPart2': '). Stocké sans perte et exporté en mode ADIF ', 'adx.fullMode': 'complet', 'adx.introPart3': '.', 'adx.addFieldPh': 'Ajouter un champ ADIF…', 'adx.showDeprecated': 'Afficher les obsolètes', 'adx.noExtra': 'Aucun champ ADIF supplémentaire. Utilise le sélecteur ci-dessus pour en ajouter un.', 'adx.deprecated': 'obsolète', 'adx.intl': 'intl', 'adx.nonStandard': 'non standard', 'adx.removeField': 'Supprimer le champ',
|
'adx.introPart1': 'Tous les champs ADIF 3.1.7 non affichés dans les autres onglets. Choisis un champ à ajouter, ou saisis un tag personnalisé/constructeur (ex. ', 'adx.introPart2': '). Stocké sans perte et exporté en mode ADIF ', 'adx.fullMode': 'complet', 'adx.introPart3': '.', 'adx.addFieldPh': 'Ajouter un champ ADIF…', 'adx.showDeprecated': 'Afficher les obsolètes', 'adx.noExtra': 'Aucun champ ADIF supplémentaire. Utilise le sélecteur ci-dessus pour en ajouter un.', 'adx.deprecated': 'obsolète', 'adx.intl': 'intl', 'adx.nonStandard': 'non standard', 'adx.removeField': 'Supprimer le champ',
|
||||||
'wkp.sending': 'Émission…', 'wkp.connectedV': 'Connecté (v{version})', 'wkp.disconnected': 'Déconnecté', 'wkp.comPort': 'Port COM', 'wkp.noPorts': 'Aucun port', 'wkp.refreshPorts': 'Rafraîchir les ports', 'wkp.connect': 'Connecter', 'wkp.disconnect': 'Déconnecter', 'wkp.hide': 'Masquer / désactiver le WinKeyer',
|
'wkp.sending': 'Émission…', 'wkp.connectedV': 'Connecté (v{version})', 'wkp.disconnected': 'Déconnecté', 'wkp.comPort': 'Port COM', 'wkp.noPorts': 'Aucun port', 'wkp.refreshPorts': 'Rafraîchir les ports', 'wkp.connect': 'Connecter', 'wkp.disconnect': 'Déconnecter', 'wkp.hide': 'Masquer / désactiver le WinKeyer',
|
||||||
|
'wkp.sourceHint': 'Sortie CW : WK = WinKeyer matériel · CI-V = le keyer interne de l’Icom (via CAT, sans matériel en plus)', 'wkp.civReady': 'Icom CI-V prêt', 'wkp.civOffline': 'Icom non connecté (Réglages → CAT)',
|
||||||
'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop',
|
'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop',
|
||||||
|
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL",
|
||||||
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
|
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
|
||||||
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1–F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message',
|
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1–F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message',
|
||||||
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.',
|
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.',
|
||||||
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
|
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
|
||||||
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR',
|
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR',
|
||||||
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto',
|
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope −50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?',
|
||||||
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
|
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
|
||||||
|
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
|
||||||
'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer',
|
'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer',
|
||||||
'spm.callRequired': 'Indicatif requis', 'spm.freqRequired': 'Fréquence (kHz) requise', 'spm.title': 'Envoyer un spot DX', 'spm.callsign': 'Indicatif', 'spm.callPh': 'Indicatif DX', 'spm.frequency': 'Fréquence (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'ex. CW · TNX QSO', 'spm.latestQsos': 'Derniers QSO', 'spm.spotSent': 'Spot envoyé ✓', 'spm.masterCluster': 'Cluster maître', 'spm.cancel': 'Annuler', 'spm.sending': 'Envoi…', 'spm.sendSpot': 'Envoyer le spot',
|
'spm.callRequired': 'Indicatif requis', 'spm.freqRequired': 'Fréquence (kHz) requise', 'spm.title': 'Envoyer un spot DX', 'spm.callsign': 'Indicatif', 'spm.callPh': 'Indicatif DX', 'spm.frequency': 'Fréquence (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'ex. CW · TNX QSO', 'spm.latestQsos': 'Derniers QSO', 'spm.spotSent': 'Spot envoyé ✓', 'spm.masterCluster': 'Cluster maître', 'spm.cancel': 'Annuler', 'spm.sending': 'Envoi…', 'spm.sendSpot': 'Envoyer le spot',
|
||||||
'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'double-clic → éditer tous les champs · « Logger & terminer » pour enregistrer', 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET',
|
'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Déjà contacté', 'ncp.wbHint': 'Cliquer une station (on air ou roster) pour voir les QSO précédents', 'ncp.wbNone': 'Aucun QSO précédent avec', 'ncp.wbFirst': 'premier', 'ncp.wbLast': 'dernier', 'ncp.wbResize': 'Glisser pour redimensionner', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'double-clic → éditer tous les champs · « Logger & terminer » pour enregistrer', 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET',
|
||||||
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "Base mise à jour → notifier d'autres apps", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer',
|
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en <PST><FREQUENCY> à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer',
|
||||||
'fltb.fCallsign': 'Indicatif', 'fltb.fDate': 'Date / heure (UTC)', 'fltb.fEndDate': 'Date / heure de fin', 'fltb.fBand': 'Bande', 'fltb.fRxBand': 'Bande RX', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Sous-mode', 'fltb.fFreq': 'Fréquence (Hz)', 'fltb.fRxFreq': 'Fréquence RX (Hz)', 'fltb.fRstSent': 'RST envoyé', 'fltb.fRstRcvd': 'RST reçu', 'fltb.fName': 'Nom', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Adresse', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Locator', 'fltb.fCountry': 'Pays', 'fltb.fState': 'État', 'fltb.fCounty': 'Comté', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'Zone CQ', 'fltb.fItuz': 'Zone ITU', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'Réf SOTA', 'fltb.fPota': 'Réf POTA', 'fltb.fWwff': 'Réf WWFF', 'fltb.fRig': 'Station', 'fltb.fAntenna': 'Antenne', 'fltb.fQslSent': 'QSL envoyée', 'fltb.fQslRcvd': 'QSL reçue', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW envoyé', 'fltb.fLotwRcvd': 'LoTW reçu', 'fltb.fEqslSent': 'eQSL envoyé', 'fltb.fEqslRcvd': 'eQSL reçu', 'fltb.fQrzUpload': 'Statut upload QRZ', 'fltb.fClublogUpload': 'Statut upload ClubLog', 'fltb.fHrdlogUpload': 'Statut upload HRDLog', 'fltb.fContestId': 'ID contest', 'fltb.fSerialRcvd': 'Numéro reçu', 'fltb.fSerialSent': 'Numéro envoyé', 'fltb.fPropMode': 'Mode de propagation', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Mon indicatif', 'fltb.fOperator': 'Opérateur', 'fltb.fOwnerCallsign': 'Indicatif propriétaire', 'fltb.fMyGrid': 'Mon locator', 'fltb.fMyCountry': 'Mon pays', 'fltb.fMyState': 'Mon état', 'fltb.fMyCounty': 'Mon comté', 'fltb.fMyIota': 'Mon IOTA', 'fltb.fMySota': 'Ma réf SOTA', 'fltb.fMyPota': 'Ma réf POTA', 'fltb.fMyWwff': 'Ma réf WWFF', 'fltb.fMyStreet': 'Ma rue', 'fltb.fMyCity': 'Ma ville', 'fltb.fMyPostal': 'Mon code postal', 'fltb.fMyRig': 'Ma station', 'fltb.fMyAntenna': 'Mon antenne', 'fltb.fTxPower': 'Puissance TX (W)', 'fltb.fComment': 'Commentaire', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer',
|
'fltb.fCallsign': 'Indicatif', 'fltb.fDate': 'Date / heure (UTC)', 'fltb.fEndDate': 'Date / heure de fin', 'fltb.fBand': 'Bande', 'fltb.fRxBand': 'Bande RX', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Sous-mode', 'fltb.fFreq': 'Fréquence (Hz)', 'fltb.fRxFreq': 'Fréquence RX (Hz)', 'fltb.fRstSent': 'RST envoyé', 'fltb.fRstRcvd': 'RST reçu', 'fltb.fName': 'Nom', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Adresse', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Locator', 'fltb.fCountry': 'Pays', 'fltb.fState': 'État', 'fltb.fCounty': 'Comté', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'Zone CQ', 'fltb.fItuz': 'Zone ITU', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'Réf SOTA', 'fltb.fPota': 'Réf POTA', 'fltb.fWwff': 'Réf WWFF', 'fltb.fRig': 'Station', 'fltb.fAntenna': 'Antenne', 'fltb.fQslSent': 'QSL envoyée', 'fltb.fQslRcvd': 'QSL reçue', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW envoyé', 'fltb.fLotwRcvd': 'LoTW reçu', 'fltb.fEqslSent': 'eQSL envoyé', 'fltb.fEqslRcvd': 'eQSL reçu', 'fltb.fQrzUpload': 'Statut upload QRZ', 'fltb.fClublogUpload': 'Statut upload ClubLog', 'fltb.fHrdlogUpload': 'Statut upload HRDLog', 'fltb.fContestId': 'ID contest', 'fltb.fSerialRcvd': 'Numéro reçu', 'fltb.fSerialSent': 'Numéro envoyé', 'fltb.fPropMode': 'Mode de propagation', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Mon indicatif', 'fltb.fOperator': 'Opérateur', 'fltb.fOwnerCallsign': 'Indicatif propriétaire', 'fltb.fMyGrid': 'Mon locator', 'fltb.fMyCountry': 'Mon pays', 'fltb.fMyState': 'Mon état', 'fltb.fMyCounty': 'Mon comté', 'fltb.fMyIota': 'Mon IOTA', 'fltb.fMySota': 'Ma réf SOTA', 'fltb.fMyPota': 'Ma réf POTA', 'fltb.fMyWwff': 'Ma réf WWFF', 'fltb.fMyStreet': 'Ma rue', 'fltb.fMyCity': 'Ma ville', 'fltb.fMyPostal': 'Mon code postal', 'fltb.fMyRig': 'Ma station', 'fltb.fMyAntenna': 'Mon antenne', 'fltb.fTxPower': 'Puissance TX (W)', 'fltb.fComment': 'Commentaire', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer',
|
||||||
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
|
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
|
||||||
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
|
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
|
||||||
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
|
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
|
||||||
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
|
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
|
||||||
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.allowMultiple': 'Autoriser plusieurs références sur un seul QSO', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches supplémentaires', 'awed.orAlsoMatch': "— correspond aussi à la référence si l'une d'elles est trouvée", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.grantCodes': "Codes d'attribution", 'awed.exportCreditGranted': 'Exporter le diplôme dans le champ ADIF credit_granted', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
|
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.allowMultiple': 'Autoriser plusieurs références sur un seul QSO', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches supplémentaires', 'awed.orAlsoMatch': "— correspond aussi à la référence si l'une d'elles est trouvée", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.grantCodes': "Codes d'attribution", 'awed.exportCreditGranted': 'Exporter le diplôme dans le champ ADIF credit_granted', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
|
||||||
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
|
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
|
||||||
'bulk.fLotwSent': 'LoTW envoyé', 'bulk.fLotwRcvd': 'LoTW reçu', 'bulk.fEqslSent': 'eQSL envoyé', 'bulk.fEqslRcvd': 'eQSL reçu', 'bulk.fQslSent': 'QSL papier envoyée', 'bulk.fQslRcvd': 'QSL papier reçue', 'bulk.fQrzUpload': 'Envoi QRZ.com', 'bulk.fClublogUpload': 'Envoi Club Log', 'bulk.fHrdlogUpload': 'Envoi HRDLog', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Indicatif de la station', 'bulk.fOperator': 'Opérateur', 'bulk.fMyGrid': 'Mon locator', 'bulk.fMyAntenna': 'Mon antenne', 'bulk.fMyRig': 'Mon équipement', 'bulk.fMyStreet': 'Ma rue', 'bulk.fMyCity': 'Ma ville', 'bulk.fMyPostal': 'Mon code postal', 'bulk.fMyCountry': 'Mon pays', 'bulk.fMyState': 'Mon état', 'bulk.fMyCounty': 'Mon comté', 'bulk.fMyIota': 'Mon IOTA', 'bulk.fMySota': 'Ma réf. SOTA', 'bulk.fMyPota': 'Ma réf. POTA', 'bulk.fMyWwff': 'Ma réf. WWFF', 'bulk.fMySig': 'Mon SIG', 'bulk.fMySigInfo': 'Mon info SIG', 'bulk.fComment': 'Commentaire', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Équipement (contacté)', 'bulk.fAnt': 'Antenne (contactée)', 'bulk.statusY': 'Y — Oui / envoyé', 'bulk.statusN': 'N — Non', 'bulk.statusR': 'R — Demandé', 'bulk.statusI': 'I — Ignorer', 'bulk.statusBlank': '(vide — effacer)', 'bulk.groupQsl': 'QSL / envoi', 'bulk.groupMyStation': 'Ma station', 'bulk.groupMisc': 'Divers', 'bulk.title': "Édition groupée d'un champ", 'bulk.desc': 'Définir un champ sur les {n} QSO sélectionné(s). Cela écrase la valeur actuelle — aucune annulation possible.', 'bulk.fieldLabel': 'Champ', 'bulk.valueLabel': 'Valeur', 'bulk.clearPlaceholder': 'laisser vide pour effacer le champ', 'bulk.willSet': 'Définira', 'bulk.blank': '(vide)', 'bulk.onQsos': 'sur {n} QSO.', 'bulk.cancel': 'Annuler', 'bulk.applyTo': 'Appliquer à {n}',
|
'bulk.fLotwSent': 'LoTW envoyé', 'bulk.fLotwRcvd': 'LoTW reçu', 'bulk.fEqslSent': 'eQSL envoyé', 'bulk.fEqslRcvd': 'eQSL reçu', 'bulk.fQslSent': 'QSL papier envoyée', 'bulk.fQslRcvd': 'QSL papier reçue', 'bulk.fQrzUpload': 'Envoi QRZ.com', 'bulk.fClublogUpload': 'Envoi Club Log', 'bulk.fHrdlogUpload': 'Envoi HRDLog', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Indicatif de la station', 'bulk.fOperator': 'Opérateur', 'bulk.fMyGrid': 'Mon locator', 'bulk.fMyAntenna': 'Mon antenne', 'bulk.fMyRig': 'Mon équipement', 'bulk.fMyStreet': 'Ma rue', 'bulk.fMyCity': 'Ma ville', 'bulk.fMyPostal': 'Mon code postal', 'bulk.fMyCountry': 'Mon pays', 'bulk.fMyState': 'Mon état', 'bulk.fMyCounty': 'Mon comté', 'bulk.fMyIota': 'Mon IOTA', 'bulk.fMySota': 'Ma réf. SOTA', 'bulk.fMyPota': 'Ma réf. POTA', 'bulk.fMyWwff': 'Ma réf. WWFF', 'bulk.fMySig': 'Mon SIG', 'bulk.fMySigInfo': 'Mon info SIG', 'bulk.fContestId': 'ID concours', 'bulk.fSrxString': 'Série reçue (échange)', 'bulk.fStxString': 'Série envoyée (échange)', 'bulk.fArrlSect': 'Section ARRL', 'bulk.fPrecedence': 'Précédence', 'bulk.fClass': 'Classe', 'bulk.fPropMode': 'Mode de propagation', 'bulk.fSatName': 'Nom du satellite', 'bulk.fSatMode': 'Mode satellite', 'bulk.fPotaRef': 'Réf POTA', 'bulk.fSotaRef': 'Réf SOTA', 'bulk.fWwffRef': 'Réf WWFF', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'Info SIG', 'bulk.fComment': 'Commentaire', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Équipement (contacté)', 'bulk.fAnt': 'Antenne (contactée)', 'bulk.statusY': 'Y — Oui / envoyé', 'bulk.statusN': 'N — Non', 'bulk.statusR': 'R — Demandé', 'bulk.statusI': 'I — Ignorer', 'bulk.statusBlank': '(vide — effacer)', 'bulk.groupQsl': 'QSL / envoi', 'bulk.groupMyStation': 'Ma station', 'bulk.groupContacted': 'Station contactée', 'bulk.groupContest': 'Concours', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Divers', 'bulk.title': "Édition groupée d'un champ", 'bulk.desc': 'Définir un champ sur les {n} QSO sélectionné(s). Cela écrase la valeur actuelle — aucune annulation possible.', 'bulk.fieldLabel': 'Champ', 'bulk.valueLabel': 'Valeur', 'bulk.clearPlaceholder': 'laisser vide pour effacer le champ', 'bulk.willSet': 'Définira', 'bulk.blank': '(vide)', 'bulk.onQsos': 'sur {n} QSO.', 'bulk.cancel': 'Annuler', 'bulk.applyTo': 'Appliquer à {n}',
|
||||||
'qslm.leave': '— laisser —', 'qslm.yes': 'Oui', 'qslm.no': 'Non', 'qslm.requested': 'Demandé', 'qslm.ignore': 'Ignorer', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Électronique', 'qslm.svcPota': 'Journal chasseur POTA', 'qslm.svcPaper': 'QSL papier', 'qslm.sentRequested': 'Demandé', 'qslm.sentNo': 'Non', 'qslm.sentQueued': 'En file', 'qslm.sentYes': 'Oui (déjà envoyé)', 'qslm.sentInvalid': 'Invalide', 'qslm.sentBlank': '— vide —', 'qslm.qsoUpdated': '{n} QSO mis à jour.', 'qslm.service': 'Service', 'qslm.callsign': 'Indicatif', 'qslm.callsignScopeTitle': "L'envoi/téléchargement est limité à cet indicatif (indicatif de station forcé, sinon celui du profil actif)", 'qslm.syncHunterLog': 'Synchroniser le journal chasseur', 'qslm.onlyMyCallTitle': "Ne synchroniser que les chasses faites sous l'indicatif de votre profil actif — ignorer les QSO faits sous un autre indicatif (ex. XV9Q, NQ2H) absents de ce journal", 'qslm.onlyMyCall': "Uniquement l'indicatif de mon profil", 'qslm.addMissingTitle': "Insérer les contacts du journal chasseur dont l'indicatif n'est pas encore dans votre journal (indicatif/date/bande/mode/parc)", 'qslm.addMissing': 'Ajouter les QSO introuvables à mon journal', 'qslm.potaToken': 'Jeton dans Réglages → Services externes → POTA.', 'qslm.callsignPlaceholder': 'ex. DL1ABC', 'qslm.search': 'Rechercher', 'qslm.paperHint': 'Trouvez un indicatif, puis définissez QSL envoyée/reçue + via + date sur la sélection.', 'qslm.sentStatus': 'Statut envoyé', 'qslm.selectRequired': 'Sélectionner les requis', 'qslm.potaSummaryShort': '{updated} mis à jour · {added} ajoutés · {already} déjà · {unmatched} sans correspondance', 'qslm.potaOtherCall': ' · {n} autre indicatif', 'qslm.paperCount': '{total} QSO · {selected} sélectionné(s)', 'qslm.filter': 'Filtre', 'qslm.filterAll': 'Tous', 'qslm.filterNew': 'Nouveau (tout)', 'qslm.filterNewDxcc': 'Nouveau DXCC', 'qslm.filterNewBand': 'Nouvelle bande', 'qslm.filterNewSlot': 'Nouveau créneau', 'qslm.results': 'Résultats', 'qslm.log': 'Journal', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} trouvé(s) · {selected} sélectionné(s)', 'qslm.paperEmpty': 'Recherchez un indicatif pour lister ses QSO, puis définissez le statut QSL ci-dessous.', 'qslm.potaEmpty': 'Cliquez sur « Synchroniser le journal chasseur » pour récupérer votre journal pota.app et tamponner les références de parc.', 'qslm.potaSyncing': 'Synchronisation avec pota.app…', 'qslm.potaSummary': '{updated} QSO mis à jour · {added} ajoutés au journal · {already} déjà tamponnés · {unmatched} sans correspondance (sur {fetched} entrées du journal chasseur).', 'qslm.potaSkipped': ' {n} chasse(s) faites sous un autre indicatif ont été ignorées', 'qslm.potaKeptOnly': ' (conservé uniquement {call})', 'qslm.potaRescan': 'Relancez le scan du diplôme POTA pour compter les nouvelles références.', 'qslm.thActivator': 'Activateur', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Bande', 'qslm.thPark': 'Parc', 'qslm.thWhyUnmatched': 'Pourquoi sans correspondance', 'qslm.openToFix': 'Ouvrir ce QSO pour le corriger', 'qslm.starting': 'démarrage…', 'qslm.working': 'en cours…', 'qslm.noNewConf': 'Aucune nouvelle confirmation.', 'qslm.noConfMatch': 'Aucune confirmation ne correspond à ce filtre.', 'qslm.thCallsign': 'Indicatif', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Pays', 'qslm.thNew': 'Nouveau ?', 'qslm.newDxcc': 'NOUVEAU DXCC', 'qslm.newBand': 'NOUVELLE BANDE', 'qslm.newSlot': 'NOUVEAU CRÉNEAU', 'qslm.uploadEmpty': 'Choisissez un service + statut envoyé, puis « Sélectionner les requis ».', 'qslm.qslReceived': 'QSL reçue', 'qslm.qslRcvdDateTitle': 'Date de réception QSL', 'qslm.qslSent': 'QSL envoyée', 'qslm.qslSentDateTitle': "Date d'envoi QSL", 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'ex. payé 3€', 'qslm.comment': 'Commentaire', 'qslm.commentPlaceholder': 'commentaire', 'qslm.applyToSelected': 'Appliquer à {n} sélectionné(s)', 'qslm.downloadTitle': 'Récupérer les confirmations du service et mettre à jour le statut reçu', 'qslm.downloadConf': 'Télécharger les confirmations', 'qslm.downloadRangeTitle': "Jusqu'où télécharger", 'qslm.sinceLast': 'Depuis le dernier téléchargement', 'qslm.sinceDate': 'Depuis une date…', 'qslm.sinceAll': 'Tout', 'qslm.sinceDateTitleQrz': 'QRZ : filtre par date de QSO (pas de filtre par date de réception côté serveur)', 'qslm.sinceDateTitleLotw': 'LoTW : confirmations reçues depuis cette date', 'qslm.addNotFoundTitle': "Insérer les QSO confirmés qui ne sont pas encore dans votre journal", 'qslm.addNotFound': 'Ajouter les introuvables', 'qslm.uploadTo': 'Envoyer {n} vers {service}',
|
'qslm.leave': '— laisser —', 'qslm.yes': 'Oui', 'qslm.no': 'Non', 'qslm.requested': 'Demandé', 'qslm.ignore': 'Ignorer', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Électronique', 'qslm.svcPota': 'Journal chasseur POTA', 'qslm.svcPaper': 'QSL papier', 'qslm.sentRequested': 'Demandé', 'qslm.sentNo': 'Non', 'qslm.sentQueued': 'En file', 'qslm.sentYes': 'Oui (déjà envoyé)', 'qslm.sentInvalid': 'Invalide', 'qslm.sentBlank': '— vide —', 'qslm.qsoUpdated': '{n} QSO mis à jour.', 'qslm.service': 'Service', 'qslm.callsign': 'Indicatif', 'qslm.callsignScopeTitle': "L'envoi/téléchargement est limité à cet indicatif (indicatif de station forcé, sinon celui du profil actif)", 'qslm.syncHunterLog': 'Synchroniser le journal chasseur', 'qslm.onlyMyCallTitle': "Ne synchroniser que les chasses faites sous l'indicatif de votre profil actif — ignorer les QSO faits sous un autre indicatif (ex. XV9Q, NQ2H) absents de ce journal", 'qslm.onlyMyCall': "Uniquement l'indicatif de mon profil", 'qslm.addMissingTitle': "Insérer les contacts du journal chasseur dont l'indicatif n'est pas encore dans votre journal (indicatif/date/bande/mode/parc)", 'qslm.addMissing': 'Ajouter les QSO introuvables à mon journal', 'qslm.potaToken': 'Jeton dans Réglages → Services externes → POTA.', 'qslm.callsignPlaceholder': 'ex. DL1ABC', 'qslm.search': 'Rechercher', 'qslm.paperHint': 'Trouvez un indicatif, puis définissez QSL envoyée/reçue + via + date sur la sélection.', 'qslm.sentStatus': 'Statut envoyé', 'qslm.selectRequired': 'Sélectionner les requis', 'qslm.potaSummaryShort': '{updated} mis à jour · {added} ajoutés · {already} déjà · {unmatched} sans correspondance', 'qslm.potaOtherCall': ' · {n} autre indicatif', 'qslm.paperCount': '{total} QSO · {selected} sélectionné(s)', 'qslm.filter': 'Filtre', 'qslm.filterAll': 'Tous', 'qslm.filterNew': 'Nouveau (tout)', 'qslm.filterNewDxcc': 'Nouveau DXCC', 'qslm.filterNewBand': 'Nouvelle bande', 'qslm.filterNewSlot': 'Nouveau créneau', 'qslm.results': 'Résultats', 'qslm.log': 'Journal', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} trouvé(s) · {selected} sélectionné(s)', 'qslm.paperEmpty': 'Recherchez un indicatif pour lister ses QSO, puis définissez le statut QSL ci-dessous.', 'qslm.potaEmpty': 'Cliquez sur « Synchroniser le journal chasseur » pour récupérer votre journal pota.app et tamponner les références de parc.', 'qslm.potaSyncing': 'Synchronisation avec pota.app…', 'qslm.potaSummary': '{updated} QSO mis à jour · {added} ajoutés au journal · {already} déjà tamponnés · {unmatched} sans correspondance (sur {fetched} entrées du journal chasseur).', 'qslm.potaSkipped': ' {n} chasse(s) faites sous un autre indicatif ont été ignorées', 'qslm.potaKeptOnly': ' (conservé uniquement {call})', 'qslm.potaRescan': 'Relancez le scan du diplôme POTA pour compter les nouvelles références.', 'qslm.thActivator': 'Activateur', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Bande', 'qslm.thPark': 'Parc', 'qslm.thWhyUnmatched': 'Pourquoi sans correspondance', 'qslm.openToFix': 'Ouvrir ce QSO pour le corriger', 'qslm.starting': 'démarrage…', 'qslm.working': 'en cours…', 'qslm.noNewConf': 'Aucune nouvelle confirmation.', 'qslm.noConfMatch': 'Aucune confirmation ne correspond à ce filtre.', 'qslm.thCallsign': 'Indicatif', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Pays', 'qslm.thNew': 'Nouveau ?', 'qslm.newDxcc': 'NOUVEAU DXCC', 'qslm.newBand': 'NOUVELLE BANDE', 'qslm.newSlot': 'NOUVEAU CRÉNEAU', 'qslm.uploadEmpty': 'Choisissez un service + statut envoyé, puis « Sélectionner les requis ».', 'qslm.qslReceived': 'QSL reçue', 'qslm.qslRcvdDateTitle': 'Date de réception QSL', 'qslm.qslSent': 'QSL envoyée', 'qslm.qslSentDateTitle': "Date d'envoi QSL", 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'ex. payé 3€', 'qslm.comment': 'Commentaire', 'qslm.commentPlaceholder': 'commentaire', 'qslm.applyToSelected': 'Appliquer à {n} sélectionné(s)', 'qslm.downloadTitle': 'Récupérer les confirmations du service et mettre à jour le statut reçu', 'qslm.downloadConf': 'Télécharger les confirmations', 'qslm.downloadRangeTitle': "Jusqu'où télécharger", 'qslm.sinceLast': 'Depuis le dernier téléchargement', 'qslm.sinceDate': 'Depuis une date…', 'qslm.sinceAll': 'Tout', 'qslm.sinceDateTitleQrz': 'QRZ : filtre par date de QSO (pas de filtre par date de réception côté serveur)', 'qslm.sinceDateTitleLotw': 'LoTW : confirmations reçues depuis cette date', 'qslm.addNotFoundTitle': "Insérer les QSO confirmés qui ne sont pas encore dans votre journal", 'qslm.addNotFound': 'Ajouter les introuvables', 'qslm.uploadTo': 'Envoyer {n} vers {service}',
|
||||||
'qedit.qslDash': '—', 'qedit.qslYes': 'Oui', 'qedit.qslNo': 'Non', 'qedit.qslRequested': 'Demandé', 'qedit.qslIgnore': 'Ignorer', 'qedit.statusModified': 'Modifié', 'qedit.confQslPaper': 'QSL (papier)', 'qedit.callsignRequired': 'Indicatif requis', 'qedit.lookupError': 'Recherche : {msg}', 'qedit.title': 'Modifier le QSO', 'qedit.editFieldsFor': 'Modifier les champs du QSO #{id}', 'qedit.tabQsoInfo': 'Infos QSO', 'qedit.tabContact': 'Détails du contact', 'qedit.tabAwards': 'Réf. diplômes', 'qedit.tabQsl': 'Infos QSL', 'qedit.tabContest': 'Concours', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'Ma station', 'qedit.tabMoreAdif': "Plus d'ADIF", 'qedit.tabAdifFields': 'Champs ADIF', 'qedit.callsign': 'Indicatif', 'qedit.fetchTitle': 'Rechercher cet indicatif (QRZ.com / HamQTH) et actualiser nom, pays, locator, zones…', 'qedit.fetch': 'Rechercher', 'qedit.name': 'Nom', 'qedit.band': 'Bande', 'qedit.rxBand': 'Bande RX', 'qedit.mode': 'Mode', 'qedit.country': 'Pays', 'qedit.dxccTitle': "Entité DXCC n° — définie automatiquement d'après le pays", 'qedit.txFreq': 'Fréq. TX', 'qedit.rxFreq': 'Fréq. RX', 'qedit.qsoStart': 'Début QSO (UTC)', 'qedit.qsoEnd': 'Fin QSO (UTC)', 'qedit.grid': 'Locator', 'qedit.comment': 'Commentaire', 'qedit.note': 'Note', 'qedit.county': 'Comté', 'qedit.state': 'État', 'qedit.continent': 'Continent', 'qedit.address': 'Adresse', 'qedit.email': 'Adresse e-mail', 'qedit.qslMsg': 'Message QSL', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Calculé (automatique)', 'qedit.computedHint': 'Dérivé des champs de ce QSO (DXCC, zones, préfixe, notes…). Non modifiable ici.', 'qedit.noneYet': "Aucun pour l'instant.", 'qedit.manageConf': 'Gérer la confirmation', 'qedit.sent': 'Envoyé', 'qedit.received': 'Reçu', 'qedit.dateSent': "Date d'envoi", 'qedit.dateReceived': 'Date de réception', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Choisissez un canal, modifiez-le — le tableau de droite se met à jour en direct. Tout est enregistré quand vous cliquez sur', 'qedit.saveChanges': 'Enregistrer', 'qedit.thType': 'Type', 'qedit.contestId': 'ID concours', 'qedit.rcvdExchange': 'échange reçu', 'qedit.sentExchange': 'échange envoyé', 'qedit.check': 'Check', 'qedit.precedence': 'Précédence', 'qedit.arrlSection': 'Section ARRL', 'qedit.propMode': 'Mode de propagation', 'qedit.satName': 'Nom du satellite', 'qedit.satMode': 'Mode satellite', 'qedit.antAz': 'Azimut antenne (°)', 'qedit.antEl': 'Élévation antenne (°)', 'qedit.antPath': "Chemin d'antenne", 'qedit.myStationHint': 'Ces valeurs remplacent le profil de station actif pour ce QSO uniquement.', 'qedit.stationCallsign': 'Indicatif de la station', 'qedit.operator': 'Opérateur', 'qedit.myGrid': 'Mon locator', 'qedit.gridExt': 'Ext. locator', 'qedit.cqZone': 'Zone CQ', 'qedit.ituZone': 'Zone ITU', 'qedit.sotaRef': 'Réf. SOTA', 'qedit.potaRef': 'Réf. POTA', 'qedit.street': 'Rue', 'qedit.city': 'Ville', 'qedit.postal': 'Code postal', 'qedit.rig': 'Équipement', 'qedit.antenna': 'Antenne', 'qedit.specialActivity': 'Activité spéciale', 'qedit.sigInfo': 'Info SIG', 'qedit.wwffRef': 'Réf. WWFF', 'qedit.region': 'Région', 'qedit.powerWeather': 'Puissance et météo spatiale', 'qedit.rxPower': 'Puissance RX (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'Indice A', 'qedit.kIndex': 'Indice K', 'qedit.identityClubs': 'Identité et clubs', 'qedit.contactedOp': 'Opérateur contacté', 'qedit.formerCall': 'Ancien indicatif (EQ_CALL)', 'qedit.class': 'Classe', 'qedit.flagsCredits': 'Indicateurs et crédits', 'qedit.qsoComplete': 'QSO complet', 'qedit.qsoRandom': 'QSO aléatoire', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Crédit accordé', 'qedit.creditSubmitted': 'Crédit soumis', 'qedit.myStationAdif': 'Ma station (ADIF)', 'qedit.myName': 'Mon nom', 'qedit.myWwffRef': 'Ma réf. WWFF', 'qedit.myArrlSect': 'Ma section ARRL', 'qedit.mySig': 'Mon SIG', 'qedit.mySigInfo': 'Mon info SIG', 'qedit.myDarcDok': 'Mon DARC DOK', 'qedit.myVuccGrids': 'Mes locators VUCC', 'qedit.delete': 'Supprimer', 'qedit.cancel': 'Annuler', 'qedit.saving': 'Enregistrement…',
|
'qedit.qslDash': '—', 'qedit.qslYes': 'Oui', 'qedit.qslNo': 'Non', 'qedit.qslRequested': 'Demandé', 'qedit.qslIgnore': 'Ignorer', 'qedit.statusModified': 'Modifié', 'qedit.confQslPaper': 'QSL (papier)', 'qedit.callsignRequired': 'Indicatif requis', 'qedit.lookupError': 'Recherche : {msg}', 'qedit.title': 'Modifier le QSO', 'qedit.editFieldsFor': 'Modifier les champs du QSO #{id}', 'qedit.tabQsoInfo': 'Infos QSO', 'qedit.tabContact': 'Détails du contact', 'qedit.tabAwards': 'Réf. diplômes', 'qedit.tabQsl': 'Infos QSL', 'qedit.tabContest': 'Concours', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'Ma station', 'qedit.tabMoreAdif': "Plus d'ADIF", 'qedit.tabAdifFields': 'Champs ADIF', 'qedit.callsign': 'Indicatif', 'qedit.fetchTitle': 'Rechercher cet indicatif (QRZ.com / HamQTH) et actualiser nom, pays, locator, zones…', 'qedit.fetch': 'Rechercher', 'qedit.name': 'Nom', 'qedit.band': 'Bande', 'qedit.rxBand': 'Bande RX', 'qedit.mode': 'Mode', 'qedit.country': 'Pays', 'qedit.dxccTitle': "Entité DXCC n° — définie automatiquement d'après le pays", 'qedit.txFreq': 'Fréq. TX', 'qedit.rxFreq': 'Fréq. RX', 'qedit.qsoStart': 'Début QSO (UTC)', 'qedit.qsoEnd': 'Fin QSO (UTC)', 'qedit.grid': 'Locator', 'qedit.comment': 'Commentaire', 'qedit.note': 'Note', 'qedit.county': 'Comté', 'qedit.state': 'État', 'qedit.continent': 'Continent', 'qedit.address': 'Adresse', 'qedit.email': 'Adresse e-mail', 'qedit.qslMsg': 'Message QSL', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Calculé (automatique)', 'qedit.computedHint': 'Dérivé des champs de ce QSO (DXCC, zones, préfixe, notes…). Non modifiable ici.', 'qedit.noneYet': "Aucun pour l'instant.", 'qedit.manageConf': 'Gérer la confirmation', 'qedit.sent': 'Envoyé', 'qedit.received': 'Reçu', 'qedit.dateSent': "Date d'envoi", 'qedit.dateReceived': 'Date de réception', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Choisissez un canal, modifiez-le — le tableau de droite se met à jour en direct. Tout est enregistré quand vous cliquez sur', 'qedit.saveChanges': 'Enregistrer', 'qedit.thType': 'Type', 'qedit.contestId': 'ID concours', 'qedit.rcvdExchange': 'échange reçu', 'qedit.sentExchange': 'échange envoyé', 'qedit.check': 'Check', 'qedit.precedence': 'Précédence', 'qedit.arrlSection': 'Section ARRL', 'qedit.propMode': 'Mode de propagation', 'qedit.satName': 'Nom du satellite', 'qedit.satMode': 'Mode satellite', 'qedit.antAz': 'Azimut antenne (°)', 'qedit.antEl': 'Élévation antenne (°)', 'qedit.antPath': "Chemin d'antenne", 'qedit.myStationHint': 'Ces valeurs remplacent le profil de station actif pour ce QSO uniquement.', 'qedit.stationCallsign': 'Indicatif de la station', 'qedit.operator': 'Opérateur', 'qedit.myGrid': 'Mon locator', 'qedit.gridExt': 'Ext. locator', 'qedit.cqZone': 'Zone CQ', 'qedit.ituZone': 'Zone ITU', 'qedit.sotaRef': 'Réf. SOTA', 'qedit.potaRef': 'Réf. POTA', 'qedit.street': 'Rue', 'qedit.city': 'Ville', 'qedit.postal': 'Code postal', 'qedit.rig': 'Équipement', 'qedit.antenna': 'Antenne', 'qedit.specialActivity': 'Activité spéciale', 'qedit.sigInfo': 'Info SIG', 'qedit.wwffRef': 'Réf. WWFF', 'qedit.region': 'Région', 'qedit.powerWeather': 'Puissance et météo spatiale', 'qedit.rxPower': 'Puissance RX (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'Indice A', 'qedit.kIndex': 'Indice K', 'qedit.identityClubs': 'Identité et clubs', 'qedit.contactedOp': 'Opérateur contacté', 'qedit.formerCall': 'Ancien indicatif (EQ_CALL)', 'qedit.class': 'Classe', 'qedit.flagsCredits': 'Indicateurs et crédits', 'qedit.qsoComplete': 'QSO complet', 'qedit.qsoRandom': 'QSO aléatoire', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Crédit accordé', 'qedit.creditSubmitted': 'Crédit soumis', 'qedit.myStationAdif': 'Ma station (ADIF)', 'qedit.myName': 'Mon nom', 'qedit.myWwffRef': 'Ma réf. WWFF', 'qedit.myArrlSect': 'Ma section ARRL', 'qedit.mySig': 'Mon SIG', 'qedit.mySigInfo': 'Mon info SIG', 'qedit.myDarcDok': 'Mon DARC DOK', 'qedit.myVuccGrids': 'Mes locators VUCC', 'qedit.delete': 'Supprimer', 'qedit.cancel': 'Annuler', 'qedit.saving': 'Enregistrement…',
|
||||||
'rqg.c.qso_date': 'Date UTC', 'rqg.c.qso_date_off': 'Date fin', 'rqg.c.callsign': 'Indicatif', 'rqg.c.band': 'Bande', 'rqg.c.band_rx': 'Bande RX', 'rqg.c.mode': 'Mode', 'rqg.c.submode': 'Sous-mode', 'rqg.c.freq_hz': 'Fréq (TX)', 'rqg.h.freq_hz': 'Fréq', 'rqg.c.freq_rx_hz': 'Fréq (RX)', 'rqg.h.freq_rx_hz': 'Fréq RX', 'rqg.c.rst_sent': 'RST env', 'rqg.c.rst_rcvd': 'RST reçu', 'rqg.c.tx_pwr': 'Puiss. TX', 'rqg.c.name': 'Nom', 'rqg.c.qth': 'QTH', 'rqg.c.address': 'Adresse', 'rqg.c.country': 'Pays', 'rqg.c.state': 'État', 'rqg.c.cnty': 'Comté', 'rqg.c.cont': 'Continent', 'rqg.h.cont': 'Cont', 'rqg.c.grid': 'Locator', 'rqg.c.gridsquare_ext': 'Ext. loc.', 'rqg.h.gridsquare_ext': 'ExtLoc', 'rqg.c.vucc_grids': 'Locators VUCC', 'rqg.h.vucc_grids': 'VUCC', 'rqg.c.dxcc': 'DXCC #', 'rqg.c.cqz': 'CQZ', 'rqg.c.ituz': 'ITU', 'rqg.c.iota': 'IOTA', 'rqg.c.sota_ref': 'Réf. SOTA', 'rqg.h.sota_ref': 'SOTA', 'rqg.c.pota_ref': 'Réf. POTA', 'rqg.h.pota_ref': 'POTA', 'rqg.c.age': 'Âge', 'rqg.c.lat': 'Lat', 'rqg.c.lon': 'Lon', 'rqg.c.email': 'E-mail', 'rqg.c.web': 'Web', 'rqg.c.qsl_sent': 'QSL env', 'rqg.c.qsl_rcvd': 'QSL reçu', 'rqg.c.qsl_sent_date': 'Date env QSL', 'rqg.h.qsl_sent_date': 'QSL env.', 'rqg.c.qsl_rcvd_date': 'Date reçu QSL', 'rqg.h.qsl_rcvd_date': 'QSL reçu', 'rqg.c.qsl_via': 'QSL via', 'rqg.c.qsl_msg': 'Msg QSL', 'rqg.c.qslmsg_rcvd': 'Msg QSL reçu', 'rqg.c.lotw_sent': 'LoTW env', 'rqg.c.lotw_rcvd': 'LoTW reçu', 'rqg.c.lotw_sent_date': 'Date env LoTW', 'rqg.h.lotw_sent_date': 'LoTW env.', 'rqg.c.lotw_rcvd_date': 'Date reçu LoTW', 'rqg.h.lotw_rcvd_date': 'LoTW reçu', 'rqg.c.eqsl_sent': 'eQSL env', 'rqg.c.eqsl_rcvd': 'eQSL reçu', 'rqg.c.eqsl_sent_date': 'Date env eQSL', 'rqg.h.eqsl_sent_date': 'eQSL env.', 'rqg.c.eqsl_rcvd_date': 'Date reçu eQSL', 'rqg.h.eqsl_rcvd_date': 'eQSL reçu', 'rqg.c.opslog_qsl_card_sent': 'QSL OpsLog', 'rqg.c.opslog_recording_sent': 'Enreg. envoyé', 'rqg.h.opslog_recording_sent': 'Enr. env', 'rqg.c.clublog_sent': 'ClubLog env', 'rqg.c.clublog_sent_date': 'Date env ClubLog', 'rqg.h.clublog_sent_date': 'ClubLog env.', 'rqg.c.hrdlog_sent': 'HRDLog env', 'rqg.c.hrdlog_sent_date': 'Date env HRDLog', 'rqg.h.hrdlog_sent_date': 'HRDLog env.', 'rqg.c.qrz_sent': 'QRZ.com env', 'rqg.c.qrz_rcvd': 'QRZ.com reçu', 'rqg.c.qrz_sent_date': 'Date env QRZ.com', 'rqg.h.qrz_sent_date': 'QRZ.com env.', 'rqg.c.qrz_rcvd_date': 'Date reçu QRZ.com', 'rqg.h.qrz_rcvd_date': 'QRZ.com reçu', 'rqg.c.contest_id': 'ID concours', 'rqg.h.contest_id': 'Concours', 'rqg.c.srx': 'SRX', 'rqg.c.stx': 'STX', 'rqg.c.srx_string': 'Chaîne SRX', 'rqg.h.srx_string': 'SRX str', 'rqg.c.stx_string': 'Chaîne STX', 'rqg.h.stx_string': 'STX str', 'rqg.c.check': 'Check', 'rqg.c.precedence': 'Précédence', 'rqg.c.arrl_sect': 'Section ARRL', 'rqg.h.arrl_sect': 'Sect. ARRL', 'rqg.c.prop_mode': 'Mode prop.', 'rqg.h.prop_mode': 'Prop', 'rqg.c.sat_name': 'Nom sat.', 'rqg.h.sat_name': 'Sat', 'rqg.c.sat_mode': 'Mode sat.', 'rqg.c.ant_az': 'Azimut ant.', 'rqg.h.ant_az': 'Az', 'rqg.c.ant_el': 'Élévation ant.', 'rqg.h.ant_el': 'Él', 'rqg.c.ant_path': 'Chemin ant.', 'rqg.h.ant_path': 'Chemin', 'rqg.c.station_callsign': 'Indicatif station', 'rqg.h.station_callsign': 'Station', 'rqg.c.operator': 'Opérateur', 'rqg.c.my_grid': 'Mon locator', 'rqg.c.my_country': 'Mon pays', 'rqg.h.my_country': 'Mon pays', 'rqg.c.my_state': 'Mon état', 'rqg.c.my_cnty': 'Mon comté', 'rqg.h.my_cnty': 'Mon comté', 'rqg.c.my_iota': 'Mon IOTA', 'rqg.c.my_sota': 'Mon SOTA', 'rqg.c.my_pota': 'Mon POTA', 'rqg.c.my_dxcc': 'Mon DXCC', 'rqg.h.my_dxcc': 'Mon DXCC#', 'rqg.c.my_cq_zone': 'Ma zone CQ', 'rqg.h.my_cq_zone': 'Ma CQZ', 'rqg.c.my_itu_zone': 'Ma zone ITU', 'rqg.h.my_itu_zone': 'Ma ITU', 'rqg.c.my_lat': 'Ma lat', 'rqg.c.my_lon': 'Ma lon', 'rqg.c.my_street': 'Ma rue', 'rqg.h.my_street': 'Rue', 'rqg.c.my_city': 'Ma ville', 'rqg.h.my_city': 'Ville', 'rqg.c.my_zip': 'Mon code postal', 'rqg.h.my_zip': 'CP', 'rqg.c.my_rig': 'Mon équipement', 'rqg.c.my_antenna': 'Mon antenne', 'rqg.h.my_antenna': 'Mon ant.', 'rqg.c.comment': 'Commentaire', 'rqg.c.notes': 'Notes', 'rqg.c.created': 'Créé', 'rqg.h.created': 'Créé le', 'rqg.c.updated': 'Mis à jour', 'rqg.h.updated': 'Mis à jour le', 'rqg.grpQso': 'QSO', 'rqg.grpContacted': 'Station contactée', 'rqg.grpQsl': 'QSL', 'rqg.grpLotw': 'LoTW', 'rqg.grpEqsl': 'eQSL', 'rqg.grpUploads': 'Envois', 'rqg.grpContest': 'Concours', 'rqg.grpProp': 'Propagation', 'rqg.grpMyStation': 'Ma station', 'rqg.grpMisc': 'Divers', 'rqg.grpAwards': 'Diplômes', 'rqg.awardTip': '{name} — référence comptée pour ce QSO', 'rqg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'rqg.clearFilters': 'Effacer les filtres', 'rqg.columns': 'Colonnes', 'rqg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau des QSO récents. Votre sélection est enregistrée.', 'rqg.all': 'tout', 'rqg.none': 'aucun', 'rqg.resetDefaults': 'Réinitialiser', 'rqg.done': 'Terminé',
|
'rqg.c.qso_date': 'Date UTC', 'rqg.c.qso_date_off': 'Date fin', 'rqg.c.callsign': 'Indicatif', 'rqg.c.band': 'Bande', 'rqg.c.band_rx': 'Bande RX', 'rqg.c.mode': 'Mode', 'rqg.c.submode': 'Sous-mode', 'rqg.c.freq_hz': 'Fréq (TX)', 'rqg.h.freq_hz': 'Fréq', 'rqg.c.freq_rx_hz': 'Fréq (RX)', 'rqg.h.freq_rx_hz': 'Fréq RX', 'rqg.c.rst_sent': 'RST env', 'rqg.c.rst_rcvd': 'RST reçu', 'rqg.c.tx_pwr': 'Puiss. TX', 'rqg.c.name': 'Nom', 'rqg.c.qth': 'QTH', 'rqg.c.address': 'Adresse', 'rqg.c.country': 'Pays', 'rqg.c.state': 'État', 'rqg.c.cnty': 'Comté', 'rqg.c.cont': 'Continent', 'rqg.h.cont': 'Cont', 'rqg.c.grid': 'Locator', 'rqg.c.gridsquare_ext': 'Ext. loc.', 'rqg.h.gridsquare_ext': 'ExtLoc', 'rqg.c.vucc_grids': 'Locators VUCC', 'rqg.h.vucc_grids': 'VUCC', 'rqg.c.dxcc': 'DXCC #', 'rqg.c.cqz': 'CQZ', 'rqg.c.ituz': 'ITU', 'rqg.c.iota': 'IOTA', 'rqg.c.sota_ref': 'Réf. SOTA', 'rqg.h.sota_ref': 'SOTA', 'rqg.c.pota_ref': 'Réf. POTA', 'rqg.h.pota_ref': 'POTA', 'rqg.c.age': 'Âge', 'rqg.c.lat': 'Lat', 'rqg.c.lon': 'Lon', 'rqg.c.email': 'E-mail', 'rqg.c.web': 'Web', 'rqg.c.qsl_sent': 'QSL env', 'rqg.c.qsl_rcvd': 'QSL reçu', 'rqg.c.qsl_sent_date': 'Date env QSL', 'rqg.h.qsl_sent_date': 'QSL env.', 'rqg.c.qsl_rcvd_date': 'Date reçu QSL', 'rqg.h.qsl_rcvd_date': 'QSL reçu', 'rqg.c.qsl_via': 'QSL via', 'rqg.c.qsl_msg': 'Msg QSL', 'rqg.c.qslmsg_rcvd': 'Msg QSL reçu', 'rqg.c.lotw_sent': 'LoTW env', 'rqg.c.lotw_rcvd': 'LoTW reçu', 'rqg.c.lotw_sent_date': 'Date env LoTW', 'rqg.h.lotw_sent_date': 'LoTW env.', 'rqg.c.lotw_rcvd_date': 'Date reçu LoTW', 'rqg.h.lotw_rcvd_date': 'LoTW reçu', 'rqg.c.eqsl_sent': 'eQSL env', 'rqg.c.eqsl_rcvd': 'eQSL reçu', 'rqg.c.eqsl_sent_date': 'Date env eQSL', 'rqg.h.eqsl_sent_date': 'eQSL env.', 'rqg.c.eqsl_rcvd_date': 'Date reçu eQSL', 'rqg.h.eqsl_rcvd_date': 'eQSL reçu', 'rqg.c.opslog_qsl_card_sent': 'QSL OpsLog', 'rqg.c.opslog_recording_sent': 'Enreg. envoyé', 'rqg.h.opslog_recording_sent': 'Enr. env', 'rqg.c.clublog_sent': 'ClubLog env', 'rqg.c.clublog_sent_date': 'Date env ClubLog', 'rqg.h.clublog_sent_date': 'ClubLog env.', 'rqg.c.hrdlog_sent': 'HRDLog env', 'rqg.c.hrdlog_sent_date': 'Date env HRDLog', 'rqg.h.hrdlog_sent_date': 'HRDLog env.', 'rqg.c.qrz_sent': 'QRZ.com env', 'rqg.c.qrz_rcvd': 'QRZ.com reçu', 'rqg.c.qrz_sent_date': 'Date env QRZ.com', 'rqg.h.qrz_sent_date': 'QRZ.com env.', 'rqg.c.qrz_rcvd_date': 'Date reçu QRZ.com', 'rqg.h.qrz_rcvd_date': 'QRZ.com reçu', 'rqg.c.contest_id': 'ID concours', 'rqg.h.contest_id': 'Concours', 'rqg.c.srx': 'SRX', 'rqg.c.stx': 'STX', 'rqg.c.srx_string': 'Chaîne SRX', 'rqg.h.srx_string': 'SRX str', 'rqg.c.stx_string': 'Chaîne STX', 'rqg.h.stx_string': 'STX str', 'rqg.c.check': 'Check', 'rqg.c.precedence': 'Précédence', 'rqg.c.arrl_sect': 'Section ARRL', 'rqg.h.arrl_sect': 'Sect. ARRL', 'rqg.c.prop_mode': 'Mode prop.', 'rqg.h.prop_mode': 'Prop', 'rqg.c.sat_name': 'Nom sat.', 'rqg.h.sat_name': 'Sat', 'rqg.c.sat_mode': 'Mode sat.', 'rqg.c.ant_az': 'Azimut ant.', 'rqg.h.ant_az': 'Az', 'rqg.c.ant_el': 'Élévation ant.', 'rqg.h.ant_el': 'Él', 'rqg.c.ant_path': 'Chemin ant.', 'rqg.h.ant_path': 'Chemin', 'rqg.c.station_callsign': 'Indicatif station', 'rqg.h.station_callsign': 'Station', 'rqg.c.operator': 'Opérateur', 'rqg.c.my_grid': 'Mon locator', 'rqg.c.my_country': 'Mon pays', 'rqg.h.my_country': 'Mon pays', 'rqg.c.my_state': 'Mon état', 'rqg.c.my_cnty': 'Mon comté', 'rqg.h.my_cnty': 'Mon comté', 'rqg.c.my_iota': 'Mon IOTA', 'rqg.c.my_sota': 'Mon SOTA', 'rqg.c.my_pota': 'Mon POTA', 'rqg.c.my_dxcc': 'Mon DXCC', 'rqg.h.my_dxcc': 'Mon DXCC#', 'rqg.c.my_cq_zone': 'Ma zone CQ', 'rqg.h.my_cq_zone': 'Ma CQZ', 'rqg.c.my_itu_zone': 'Ma zone ITU', 'rqg.h.my_itu_zone': 'Ma ITU', 'rqg.c.my_lat': 'Ma lat', 'rqg.c.my_lon': 'Ma lon', 'rqg.c.my_street': 'Ma rue', 'rqg.h.my_street': 'Rue', 'rqg.c.my_city': 'Ma ville', 'rqg.h.my_city': 'Ville', 'rqg.c.my_zip': 'Mon code postal', 'rqg.h.my_zip': 'CP', 'rqg.c.my_rig': 'Mon équipement', 'rqg.c.my_antenna': 'Mon antenne', 'rqg.h.my_antenna': 'Mon ant.', 'rqg.c.comment': 'Commentaire', 'rqg.c.notes': 'Notes', 'rqg.c.created': 'Créé', 'rqg.h.created': 'Créé le', 'rqg.c.updated': 'Mis à jour', 'rqg.h.updated': 'Mis à jour le', 'rqg.grpQso': 'QSO', 'rqg.grpContacted': 'Station contactée', 'rqg.grpQsl': 'QSL', 'rqg.grpLotw': 'LoTW', 'rqg.grpEqsl': 'eQSL', 'rqg.grpUploads': 'Envois', 'rqg.grpContest': 'Concours', 'rqg.grpProp': 'Propagation', 'rqg.grpMyStation': 'Ma station', 'rqg.grpMisc': 'Divers', 'rqg.grpAwards': 'Diplômes', 'rqg.awardTip': '{name} — référence comptée pour ce QSO', 'rqg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'rqg.clearFilters': 'Effacer les filtres', 'rqg.columns': 'Colonnes', 'rqg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau des QSO récents. Votre sélection est enregistrée.', 'rqg.all': 'tout', 'rqg.none': 'aucun', 'rqg.resetDefaults': 'Réinitialiser', 'rqg.done': 'Terminé',
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||||
|
import { writeUiPref } from './uiPref';
|
||||||
|
|
||||||
|
// Theme system. Each choice maps to a `data-theme` value on <html> that the
|
||||||
|
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
||||||
|
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
||||||
|
// travels with the data/ folder like the language).
|
||||||
|
export type ThemeChoice = 'auto' | 'light-warm' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
|
||||||
|
|
||||||
|
// Selectable, concrete themes (excludes 'auto') in display order.
|
||||||
|
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
||||||
|
'light-warm', 'dark-warm', 'dark-graphite', 'high-contrast',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LS_KEY = 'opslog.theme';
|
||||||
|
const DEFAULT: ThemeChoice = 'light-warm';
|
||||||
|
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
|
||||||
|
|
||||||
|
function systemDark(): boolean {
|
||||||
|
try { return window.matchMedia('(prefers-color-scheme: dark)').matches; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve 'auto' to a concrete data-theme value (graphite dark / warm light).
|
||||||
|
function resolve(choice: ThemeChoice): string {
|
||||||
|
if (choice === 'auto') return systemDark() ? 'dark-graphite' : 'light-warm';
|
||||||
|
return choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStored(): ThemeChoice {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(LS_KEY) as ThemeChoice | null;
|
||||||
|
if (v && ALL.includes(v)) return v;
|
||||||
|
} catch { /* private mode */ }
|
||||||
|
return DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp the resolved theme onto <html data-theme>.
|
||||||
|
export function applyThemeToDom(choice: ThemeChoice): void {
|
||||||
|
try { document.documentElement.setAttribute('data-theme', resolve(choice)); } catch { /* no DOM */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call before the first render (main.tsx) so there is no flash of the default
|
||||||
|
// palette while React boots.
|
||||||
|
export function initTheme(): void { applyThemeToDom(readStored()); }
|
||||||
|
|
||||||
|
type Ctx = { theme: ThemeChoice; setTheme: (t: ThemeChoice) => void };
|
||||||
|
const ThemeCtx = createContext<Ctx>({ theme: DEFAULT, setTheme: () => {} });
|
||||||
|
export function useTheme(): Ctx { return useContext(ThemeCtx); }
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setThemeState] = useState<ThemeChoice>(() => readStored());
|
||||||
|
|
||||||
|
const setTheme = useCallback((t: ThemeChoice) => {
|
||||||
|
setThemeState(t);
|
||||||
|
applyThemeToDom(t);
|
||||||
|
writeUiPref(LS_KEY, t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// While in 'auto', re-resolve when the OS light/dark preference flips.
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme !== 'auto') return;
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const onChange = () => applyThemeToDom('auto');
|
||||||
|
mq.addEventListener('change', onChange);
|
||||||
|
return () => mq.removeEventListener('change', onChange);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
// Keep the DOM attribute in sync with state.
|
||||||
|
useEffect(() => { applyThemeToDom(theme); }, [theme]);
|
||||||
|
|
||||||
|
return <ThemeCtx.Provider value={{ theme, setTheme }}>{children}</ThemeCtx.Provider>;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
|||||||
// Keys that must travel with data/ (DB is the portable source of truth; the
|
// Keys that must travel with data/ (DB is the portable source of truth; the
|
||||||
// localStorage copy is just a fast, synchronous cache).
|
// localStorage copy is just a fast, synchronous cache).
|
||||||
const PORTABLE_KEYS = [
|
const PORTABLE_KEYS = [
|
||||||
|
'opslog.theme', // UI colour theme (light-warm / dark-warm / graphite / high-contrast / auto)
|
||||||
'hamlog.qsoLimit', // QSO list page size
|
'hamlog.qsoLimit', // QSO list page size
|
||||||
'bandmap.side', // band map docked left / right
|
'bandmap.side', // band map docked left / right
|
||||||
'bandmap.show', // band map open / closed
|
'bandmap.show', // band map open / closed
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import './style.css'
|
|||||||
import App from './App'
|
import App from './App'
|
||||||
import { syncPortablePrefs } from './lib/uiPref'
|
import { syncPortablePrefs } from './lib/uiPref'
|
||||||
import { I18nProvider } from './lib/i18n'
|
import { I18nProvider } from './lib/i18n'
|
||||||
|
import { ThemeProvider, initTheme } from './lib/theme'
|
||||||
|
|
||||||
const container = document.getElementById('root')
|
const container = document.getElementById('root')
|
||||||
|
|
||||||
@@ -13,10 +14,15 @@ const root = createRoot(container!)
|
|||||||
// app's synchronous reads see the values copied along with the data/ folder.
|
// app's synchronous reads see the values copied along with the data/ folder.
|
||||||
// Render regardless of the outcome so a backend hiccup never blocks startup.
|
// Render regardless of the outcome so a backend hiccup never blocks startup.
|
||||||
syncPortablePrefs().finally(() => {
|
syncPortablePrefs().finally(() => {
|
||||||
|
// Stamp the persisted theme onto <html> before render so the correct
|
||||||
|
// palette is applied immediately (portable pref hydrated just above).
|
||||||
|
initTheme()
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
|
<ThemeProvider>
|
||||||
<App/>
|
<App/>
|
||||||
|
</ThemeProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
+349
-35
@@ -3,43 +3,358 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
|
||||||
/* ===== Warm taupe & burnt-orange palette =====
|
/* ============================================================================
|
||||||
A step darker than pure cream: the body is a soft taupe so white-ish
|
THEME SYSTEM
|
||||||
cards lift off the surface, giving real depth without going dark.
|
----------------------------------------------------------------------------
|
||||||
Borders are more defined; muted surfaces sit between bg and card so
|
Every color is a semantic CSS variable declared per theme below. The
|
||||||
secondary chrome (toolbars, table headers) reads as quietly recessed.
|
`@theme inline` block points Tailwind's color utilities (bg-*, text-*,
|
||||||
*/
|
border-*, ring-*, …) directly at those variables, so switching the
|
||||||
@theme {
|
`data-theme` attribute on <html> reskins the whole UI at runtime with no
|
||||||
--color-background: #e8dfc9; /* warm taupe — deeper than cream */
|
rebuild. Four themes ship: two light-neutral surfaces and status families
|
||||||
--color-foreground: #1c1917; /* stone-900 */
|
for OK / warning / caution / danger / info that adapt to each surface.
|
||||||
|
|
||||||
--color-card: #faf6ea; /* lifted off-white cream */
|
Semantic status vocabulary (use these, NOT raw palette colors):
|
||||||
--color-card-foreground: #1c1917;
|
success — worked / confirmed / data / OK (was emerald)
|
||||||
|
warning — new-band / attention (was amber)
|
||||||
|
caution — new-mode / new-slot / lighter alert (was yellow)
|
||||||
|
danger — new / split / negative (was rose)
|
||||||
|
info — informational (was sky/blue)
|
||||||
|
destructive — hard errors / live REC / delete (was red)
|
||||||
|
Each family has: base (dots/bars/solid), -foreground (text on base),
|
||||||
|
-muted (soft pill fill), -muted-foreground (text on pill), -border.
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
--color-popover: #faf6ea;
|
/* ---- Theme 1: Warm light (default) — warm taupe & burnt orange ---------- */
|
||||||
--color-popover-foreground: #1c1917;
|
:root,
|
||||||
|
[data-theme="light-warm"] {
|
||||||
|
--background: #e8dfc9; /* warm taupe — deeper than cream */
|
||||||
|
--foreground: #1c1917; /* stone-900 */
|
||||||
|
--card: #faf6ea; /* lifted off-white cream */
|
||||||
|
--card-foreground: #1c1917;
|
||||||
|
--popover: #faf6ea;
|
||||||
|
--popover-foreground: #1c1917;
|
||||||
|
--primary: #b8410c; /* burnt orange, slightly deeper */
|
||||||
|
--primary-foreground: #fff7ed;
|
||||||
|
--secondary: #ddd2b8;
|
||||||
|
--secondary-foreground: #1c1917;
|
||||||
|
--muted: #ddd2b8; /* toolbars / table headers */
|
||||||
|
--muted-foreground: #44403c; /* stone-700 — readable on muted */
|
||||||
|
--accent: #f8d6a9; /* warm amber-cream */
|
||||||
|
--accent-foreground: #7c2d12; /* orange-900 */
|
||||||
|
--destructive: #a51c1c;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--destructive-muted: #fef2f2;
|
||||||
|
--destructive-muted-foreground: #b91c1c;
|
||||||
|
--border: #c8b994; /* deeper warm beige border */
|
||||||
|
--input: #c8b994;
|
||||||
|
--ring: #d97706; /* amber-600 focus ring */
|
||||||
|
|
||||||
--color-primary: #b8410c; /* burnt orange, slightly deeper */
|
--success: #16a34a;
|
||||||
--color-primary-foreground: #fff7ed;
|
--success-foreground: #ffffff;
|
||||||
|
--success-muted: #dcfce7;
|
||||||
|
--success-muted-foreground: #166534;
|
||||||
|
--success-border: #86efac;
|
||||||
|
|
||||||
--color-secondary: #ddd2b8; /* a touch under the bg */
|
--warning: #d97706;
|
||||||
--color-secondary-foreground: #1c1917;
|
--warning-foreground: #ffffff;
|
||||||
|
--warning-muted: #fef3c7;
|
||||||
|
--warning-muted-foreground: #92400e;
|
||||||
|
--warning-border: #fcd34d;
|
||||||
|
|
||||||
--color-muted: #ddd2b8; /* toolbars / table headers */
|
--caution: #ca8a04;
|
||||||
--color-muted-foreground: #44403c; /* stone-700 — readable on muted */
|
--caution-foreground: #1c1917;
|
||||||
|
--caution-muted: #fef9c3;
|
||||||
|
--caution-muted-foreground: #854d0e;
|
||||||
|
--caution-border: #fde047;
|
||||||
|
|
||||||
--color-accent: #f8d6a9; /* warm amber-cream */
|
--danger: #e11d48;
|
||||||
--color-accent-foreground: #7c2d12; /* orange-900 */
|
--danger-foreground: #ffffff;
|
||||||
|
--danger-muted: #ffe4e6;
|
||||||
|
--danger-muted-foreground: #9f1239;
|
||||||
|
--danger-border: #fda4af;
|
||||||
|
|
||||||
--color-destructive: #a51c1c;
|
--info: #0284c7;
|
||||||
--color-destructive-foreground: #ffffff;
|
--info-foreground: #ffffff;
|
||||||
|
--info-muted: #e0f2fe;
|
||||||
|
--info-muted-foreground: #075985;
|
||||||
|
--info-border: #7dd3fc;
|
||||||
|
|
||||||
--color-border: #c8b994; /* deeper warm beige border */
|
/* Band/mode matrix (BandSlotGrid) — original emerald/indigo/stone ramp. */
|
||||||
--color-input: #c8b994;
|
--mx-call-conf: #15803d; /* call confirmed */
|
||||||
--color-ring: #d97706; /* amber-600 focus ring */
|
--mx-call-work: #6ee7b7; /* call worked */
|
||||||
|
--mx-dx-conf: #3730a3; /* entity confirmed*/
|
||||||
|
--mx-dx-work: #a5b4fc; /* entity worked */
|
||||||
|
--mx-none: #e7e5e4; /* never worked */
|
||||||
|
|
||||||
--color-ok: #15803d;
|
--scrollbar-thumb: #b8a880;
|
||||||
--color-warn: #b45309;
|
--scrollbar-thumb-hover: #968455;
|
||||||
|
--card-shadow: 0 1px 2px rgba(28, 25, 23, 0.05), 0 0 0 1px rgba(28, 25, 23, 0.02);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 2: Warm dark (espresso) — same soul, night mode ------------- */
|
||||||
|
[data-theme="dark-warm"] {
|
||||||
|
--background: #221d18;
|
||||||
|
--foreground: #ede6d8;
|
||||||
|
--card: #2e2820;
|
||||||
|
--card-foreground: #ede6d8;
|
||||||
|
--popover: #2e2820;
|
||||||
|
--popover-foreground: #ede6d8;
|
||||||
|
--primary: #e07a2e; /* brighter burnt orange for dark */
|
||||||
|
--primary-foreground: #1a1512;
|
||||||
|
--secondary: #3a3229;
|
||||||
|
--secondary-foreground: #ede6d8;
|
||||||
|
--muted: #352e26;
|
||||||
|
--muted-foreground: #bcae9a;
|
||||||
|
--accent: #4a3a28;
|
||||||
|
--accent-foreground: #f8d6a9;
|
||||||
|
--destructive: #e5484d;
|
||||||
|
--destructive-foreground: #1a1512;
|
||||||
|
--destructive-muted: #3a1414;
|
||||||
|
--destructive-muted-foreground: #fca5a5;
|
||||||
|
--border: #473e33;
|
||||||
|
--input: #473e33;
|
||||||
|
--ring: #e8853a;
|
||||||
|
|
||||||
|
--success: #34d399;
|
||||||
|
--success-foreground: #0e2a20;
|
||||||
|
--success-muted: #123024;
|
||||||
|
--success-muted-foreground: #6ee7b7;
|
||||||
|
--success-border: #1e5540;
|
||||||
|
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-foreground: #2a1f08;
|
||||||
|
--warning-muted: #3a2e10;
|
||||||
|
--warning-muted-foreground: #fcd34d;
|
||||||
|
--warning-border: #5c4a18;
|
||||||
|
|
||||||
|
--caution: #facc15;
|
||||||
|
--caution-foreground: #2a2607;
|
||||||
|
--caution-muted: #38330f;
|
||||||
|
--caution-muted-foreground: #fde047;
|
||||||
|
--caution-border: #574f18;
|
||||||
|
|
||||||
|
--danger: #fb7185;
|
||||||
|
--danger-foreground: #2a0d13;
|
||||||
|
--danger-muted: #3a1720;
|
||||||
|
--danger-muted-foreground: #fda4af;
|
||||||
|
--danger-border: #5c2530;
|
||||||
|
|
||||||
|
--info: #38bdf8;
|
||||||
|
--info-foreground: #08222e;
|
||||||
|
--info-muted: #0c2a3a;
|
||||||
|
--info-muted-foreground: #7dd3fc;
|
||||||
|
--info-border: #164a5c;
|
||||||
|
|
||||||
|
/* Matrix: bright confirmed, medium-solid worked (clearly visible on dark),
|
||||||
|
warm-gray not-worked lifted off the card. */
|
||||||
|
--mx-call-conf: #22c55e;
|
||||||
|
--mx-call-work: #2f7d55;
|
||||||
|
--mx-dx-conf: #6366f1;
|
||||||
|
--mx-dx-work: #3f3f8a;
|
||||||
|
--mx-none: #453d33;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #5c5044;
|
||||||
|
--scrollbar-thumb-hover: #7a6a58;
|
||||||
|
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 240, 220, 0.04);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 3: Graphite dark — cool slate, technical look --------------- */
|
||||||
|
[data-theme="dark-graphite"] {
|
||||||
|
--background: #16181d;
|
||||||
|
--foreground: #e6e8ec;
|
||||||
|
--card: #1f232b;
|
||||||
|
--card-foreground: #e6e8ec;
|
||||||
|
--popover: #1f232b;
|
||||||
|
--popover-foreground: #e6e8ec;
|
||||||
|
--primary: #f97316; /* orange-500 pops on slate */
|
||||||
|
--primary-foreground: #0a0c10;
|
||||||
|
--secondary: #272c35;
|
||||||
|
--secondary-foreground: #e6e8ec;
|
||||||
|
--muted: #232830;
|
||||||
|
--muted-foreground: #9aa2b1;
|
||||||
|
--accent: #2a3240;
|
||||||
|
--accent-foreground: #cbd5e5;
|
||||||
|
--destructive: #ef4444;
|
||||||
|
--destructive-foreground: #0a0c10;
|
||||||
|
--destructive-muted: #331414;
|
||||||
|
--destructive-muted-foreground: #fca5a5;
|
||||||
|
--border: #2e343f;
|
||||||
|
--input: #2e343f;
|
||||||
|
--ring: #fb8c3c;
|
||||||
|
|
||||||
|
--success: #34d399;
|
||||||
|
--success-foreground: #06231a;
|
||||||
|
--success-muted: #0f2e26;
|
||||||
|
--success-muted-foreground: #6ee7b7;
|
||||||
|
--success-border: #17513f;
|
||||||
|
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-foreground: #241a06;
|
||||||
|
--warning-muted: #33290f;
|
||||||
|
--warning-muted-foreground: #fcd34d;
|
||||||
|
--warning-border: #4f3f16;
|
||||||
|
|
||||||
|
--caution: #facc15;
|
||||||
|
--caution-foreground: #242006;
|
||||||
|
--caution-muted: #322e0e;
|
||||||
|
--caution-muted-foreground: #fde047;
|
||||||
|
--caution-border: #4c4416;
|
||||||
|
|
||||||
|
--danger: #fb7185;
|
||||||
|
--danger-foreground: #260a11;
|
||||||
|
--danger-muted: #34141d;
|
||||||
|
--danger-muted-foreground: #fda4af;
|
||||||
|
--danger-border: #532430;
|
||||||
|
|
||||||
|
--info: #38bdf8;
|
||||||
|
--info-foreground: #061f2b;
|
||||||
|
--info-muted: #0b2938;
|
||||||
|
--info-muted-foreground: #7dd3fc;
|
||||||
|
--info-border: #124659;
|
||||||
|
|
||||||
|
/* Matrix: bright confirmed, medium-solid worked, cool-gray not-worked. */
|
||||||
|
--mx-call-conf: #22c55e;
|
||||||
|
--mx-call-work: #2f7d55;
|
||||||
|
--mx-dx-conf: #6366f1;
|
||||||
|
--mx-dx-work: #3f3f8a;
|
||||||
|
--mx-none: #333a45;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #3a4150;
|
||||||
|
--scrollbar-thumb-hover: #4e5768;
|
||||||
|
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(230, 232, 236, 0.04);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 4: High contrast — pure black / white, max legibility ------- */
|
||||||
|
[data-theme="high-contrast"] {
|
||||||
|
--background: #000000;
|
||||||
|
--foreground: #ffffff;
|
||||||
|
--card: #0d0d0d;
|
||||||
|
--card-foreground: #ffffff;
|
||||||
|
--popover: #0d0d0d;
|
||||||
|
--popover-foreground: #ffffff;
|
||||||
|
--primary: #ff7a1a;
|
||||||
|
--primary-foreground: #000000;
|
||||||
|
--secondary: #1a1a1a;
|
||||||
|
--secondary-foreground: #ffffff;
|
||||||
|
--muted: #141414;
|
||||||
|
--muted-foreground: #d4d4d4;
|
||||||
|
--accent: #262626;
|
||||||
|
--accent-foreground: #ffffff;
|
||||||
|
--destructive: #ff4d4d;
|
||||||
|
--destructive-foreground: #000000;
|
||||||
|
--destructive-muted: #2a0808;
|
||||||
|
--destructive-muted-foreground: #ff9999;
|
||||||
|
--border: #3f3f3f;
|
||||||
|
--input: #3f3f3f;
|
||||||
|
--ring: #ffab5e;
|
||||||
|
|
||||||
|
--success: #22e37a;
|
||||||
|
--success-foreground: #002313;
|
||||||
|
--success-muted: #06231a;
|
||||||
|
--success-muted-foreground: #6affb0;
|
||||||
|
--success-border: #0f6b4a;
|
||||||
|
|
||||||
|
--warning: #ffcc33;
|
||||||
|
--warning-foreground: #201900;
|
||||||
|
--warning-muted: #2a2205;
|
||||||
|
--warning-muted-foreground: #ffe08a;
|
||||||
|
--warning-border: #6b5510;
|
||||||
|
|
||||||
|
--caution: #ffe14d;
|
||||||
|
--caution-foreground: #201d00;
|
||||||
|
--caution-muted: #2a2705;
|
||||||
|
--caution-muted-foreground: #fff08a;
|
||||||
|
--caution-border: #6b6210;
|
||||||
|
|
||||||
|
--danger: #ff6b7d;
|
||||||
|
--danger-foreground: #23000a;
|
||||||
|
--danger-muted: #2a0a12;
|
||||||
|
--danger-muted-foreground: #ffb3bd;
|
||||||
|
--danger-border: #6b1f2c;
|
||||||
|
|
||||||
|
--info: #4dc4ff;
|
||||||
|
--info-foreground: #001a26;
|
||||||
|
--info-muted: #052330;
|
||||||
|
--info-muted-foreground: #a3e0ff;
|
||||||
|
--info-border: #0f5a6b;
|
||||||
|
|
||||||
|
/* Matrix: max-contrast ramp on pure black. */
|
||||||
|
--mx-call-conf: #2ee87f;
|
||||||
|
--mx-call-work: #17a85a;
|
||||||
|
--mx-dx-conf: #7d97ff;
|
||||||
|
--mx-dx-work: #3646b0;
|
||||||
|
--mx-none: #3a3a3a;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #4a4a4a;
|
||||||
|
--scrollbar-thumb-hover: #666666;
|
||||||
|
--card-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map Tailwind's color utilities onto the semantic vars above. `inline` makes
|
||||||
|
the generated utilities reference var(--…) directly, so overriding a var in
|
||||||
|
a [data-theme] block re-skins every utility at runtime. */
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-destructive-muted: var(--destructive-muted);
|
||||||
|
--color-destructive-muted-foreground: var(--destructive-muted-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-success-foreground: var(--success-foreground);
|
||||||
|
--color-success-muted: var(--success-muted);
|
||||||
|
--color-success-muted-foreground: var(--success-muted-foreground);
|
||||||
|
--color-success-border: var(--success-border);
|
||||||
|
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
--color-warning-muted: var(--warning-muted);
|
||||||
|
--color-warning-muted-foreground: var(--warning-muted-foreground);
|
||||||
|
--color-warning-border: var(--warning-border);
|
||||||
|
|
||||||
|
--color-caution: var(--caution);
|
||||||
|
--color-caution-foreground: var(--caution-foreground);
|
||||||
|
--color-caution-muted: var(--caution-muted);
|
||||||
|
--color-caution-muted-foreground: var(--caution-muted-foreground);
|
||||||
|
--color-caution-border: var(--caution-border);
|
||||||
|
|
||||||
|
--color-danger: var(--danger);
|
||||||
|
--color-danger-foreground: var(--danger-foreground);
|
||||||
|
--color-danger-muted: var(--danger-muted);
|
||||||
|
--color-danger-muted-foreground: var(--danger-muted-foreground);
|
||||||
|
--color-danger-border: var(--danger-border);
|
||||||
|
|
||||||
|
--color-info: var(--info);
|
||||||
|
--color-info-foreground: var(--info-foreground);
|
||||||
|
--color-info-muted: var(--info-muted);
|
||||||
|
--color-info-muted-foreground: var(--info-muted-foreground);
|
||||||
|
--color-info-border: var(--info-border);
|
||||||
|
|
||||||
|
--color-mx-call-conf: var(--mx-call-conf);
|
||||||
|
--color-mx-call-work: var(--mx-call-work);
|
||||||
|
--color-mx-dx-conf: var(--mx-dx-conf);
|
||||||
|
--color-mx-dx-work: var(--mx-dx-work);
|
||||||
|
--color-mx-none: var(--mx-none);
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
@@ -65,18 +380,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Subtle elevation on every Card-styled surface so cards visibly sit on
|
/* Subtle elevation on every Card-styled surface so cards visibly sit on
|
||||||
top of the taupe background — paper-on-paper feel. */
|
top of the background — paper-on-paper feel, tuned per theme. */
|
||||||
.bg-card {
|
.bg-card {
|
||||||
box-shadow: 0 1px 2px rgba(28, 25, 23, 0.05),
|
box-shadow: var(--card-shadow);
|
||||||
0 0 0 1px rgba(28, 25, 23, 0.02);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Warm scrollbar tuned to the deeper bg */
|
/* Scrollbar tuned to each theme's surface */
|
||||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #b8a880;
|
background: var(--scrollbar-thumb);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border: 2px solid var(--color-background);
|
border: 2px solid var(--background);
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover { background: #968455; }
|
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
|
||||||
|
|||||||
@@ -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.16.5';
|
export const APP_VERSION = '0.19.1';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+52
@@ -172,6 +172,8 @@ export function FlexSetAPFLevel(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetATUMemories(arg1:boolean):Promise<void>;
|
export function FlexSetATUMemories(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetActiveSlice(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAudioLevel(arg1:number):Promise<void>;
|
export function FlexSetAudioLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetCWBreakInDelay(arg1:number):Promise<void>;
|
export function FlexSetCWBreakInDelay(arg1:number):Promise<void>;
|
||||||
@@ -216,6 +218,8 @@ export function FlexSetSplit(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetTXAntenna(arg1:string):Promise<void>;
|
export function FlexSetTXAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetTXSlice(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetTunePower(arg1:number):Promise<void>;
|
export function FlexSetTunePower(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetVox(arg1:boolean):Promise<void>;
|
export function FlexSetVox(arg1:boolean):Promise<void>;
|
||||||
@@ -342,18 +346,40 @@ export function IcomRefresh():Promise<void>;
|
|||||||
|
|
||||||
export function IcomScopeData():Promise<cat.ScopeSweep>;
|
export function IcomScopeData():Promise<cat.ScopeSweep>;
|
||||||
|
|
||||||
|
export function IcomSendCW(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetAFGain(arg1:number):Promise<void>;
|
export function IcomSetAFGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetAGC(arg1:string):Promise<void>;
|
export function IcomSetAGC(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetANF(arg1:boolean):Promise<void>;
|
export function IcomSetANF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetAPF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetAntenna(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetAntiVOX(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetAtt(arg1:number):Promise<void>;
|
export function IcomSetAtt(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetBreakIn(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetComp(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetCompLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetFilter(arg1:number):Promise<void>;
|
export function IcomSetFilter(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetKeySpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetManualNotch(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetMicGain(arg1:number):Promise<void>;
|
export function IcomSetMicGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetMonLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetMonitor(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetNB(arg1:boolean):Promise<void>;
|
export function IcomSetNB(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetNBLevel(arg1:number):Promise<void>;
|
export function IcomSetNBLevel(arg1:number):Promise<void>;
|
||||||
@@ -362,20 +388,44 @@ export function IcomSetNR(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function IcomSetNRLevel(arg1:number):Promise<void>;
|
export function IcomSetNRLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetNotchPos(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetPBTInner(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetPBTOuter(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetPTT(arg1:boolean):Promise<void>;
|
export function IcomSetPTT(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetPower(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetPreamp(arg1:number):Promise<void>;
|
export function IcomSetPreamp(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetRFGain(arg1:number):Promise<void>;
|
export function IcomSetRFGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetRFPower(arg1:number):Promise<void>;
|
export function IcomSetRFPower(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetRIT(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetRITOn(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetScope(arg1:boolean):Promise<void>;
|
export function IcomSetScope(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetScopeEdges(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetScopeMode(arg1:boolean):Promise<void>;
|
export function IcomSetScopeMode(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetSplit(arg1:boolean):Promise<void>;
|
export function IcomSetSplit(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetSquelch(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetVOX(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetVOXGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetXITOn(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomStopCW():Promise<void>;
|
||||||
|
|
||||||
export function IcomTune():Promise<void>;
|
export function IcomTune():Promise<void>;
|
||||||
|
|
||||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
||||||
@@ -538,6 +588,8 @@ export function ResetAwardDefs():Promise<Array<award.Def>>;
|
|||||||
|
|
||||||
export function ResetDatabaseToDefault():Promise<void>;
|
export function ResetDatabaseToDefault():Promise<void>;
|
||||||
|
|
||||||
|
export function RestartApp():Promise<void>;
|
||||||
|
|
||||||
export function RestartQSORecorder():Promise<void>;
|
export function RestartQSORecorder():Promise<void>;
|
||||||
|
|
||||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|||||||
@@ -306,6 +306,10 @@ export function FlexSetATUMemories(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetATUMemories'](arg1);
|
return window['go']['main']['App']['FlexSetATUMemories'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetActiveSlice(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetActiveSlice'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetAudioLevel(arg1) {
|
export function FlexSetAudioLevel(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
|
return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
|
||||||
}
|
}
|
||||||
@@ -394,6 +398,10 @@ export function FlexSetTXAntenna(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
|
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetTXSlice(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetTXSlice'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetTunePower(arg1) {
|
export function FlexSetTunePower(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetTunePower'](arg1);
|
return window['go']['main']['App']['FlexSetTunePower'](arg1);
|
||||||
}
|
}
|
||||||
@@ -646,6 +654,10 @@ export function IcomScopeData() {
|
|||||||
return window['go']['main']['App']['IcomScopeData']();
|
return window['go']['main']['App']['IcomScopeData']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IcomSendCW(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSendCW'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function IcomSetAFGain(arg1) {
|
export function IcomSetAFGain(arg1) {
|
||||||
return window['go']['main']['App']['IcomSetAFGain'](arg1);
|
return window['go']['main']['App']['IcomSetAFGain'](arg1);
|
||||||
}
|
}
|
||||||
@@ -658,18 +670,58 @@ export function IcomSetANF(arg1) {
|
|||||||
return window['go']['main']['App']['IcomSetANF'](arg1);
|
return window['go']['main']['App']['IcomSetANF'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IcomSetAPF(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetAPF'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetAntenna(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetAntenna'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetAntiVOX(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetAntiVOX'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function IcomSetAtt(arg1) {
|
export function IcomSetAtt(arg1) {
|
||||||
return window['go']['main']['App']['IcomSetAtt'](arg1);
|
return window['go']['main']['App']['IcomSetAtt'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IcomSetBreakIn(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetBreakIn'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetComp(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetComp'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetCompLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetCompLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function IcomSetFilter(arg1) {
|
export function IcomSetFilter(arg1) {
|
||||||
return window['go']['main']['App']['IcomSetFilter'](arg1);
|
return window['go']['main']['App']['IcomSetFilter'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IcomSetKeySpeed(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetKeySpeed'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetManualNotch(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetManualNotch'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function IcomSetMicGain(arg1) {
|
export function IcomSetMicGain(arg1) {
|
||||||
return window['go']['main']['App']['IcomSetMicGain'](arg1);
|
return window['go']['main']['App']['IcomSetMicGain'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IcomSetMonLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetMonLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetMonitor(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetMonitor'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function IcomSetNB(arg1) {
|
export function IcomSetNB(arg1) {
|
||||||
return window['go']['main']['App']['IcomSetNB'](arg1);
|
return window['go']['main']['App']['IcomSetNB'](arg1);
|
||||||
}
|
}
|
||||||
@@ -686,10 +738,26 @@ export function IcomSetNRLevel(arg1) {
|
|||||||
return window['go']['main']['App']['IcomSetNRLevel'](arg1);
|
return window['go']['main']['App']['IcomSetNRLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IcomSetNotchPos(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetNotchPos'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetPBTInner(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetPBTInner'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetPBTOuter(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetPBTOuter'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function IcomSetPTT(arg1) {
|
export function IcomSetPTT(arg1) {
|
||||||
return window['go']['main']['App']['IcomSetPTT'](arg1);
|
return window['go']['main']['App']['IcomSetPTT'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IcomSetPower(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetPower'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function IcomSetPreamp(arg1) {
|
export function IcomSetPreamp(arg1) {
|
||||||
return window['go']['main']['App']['IcomSetPreamp'](arg1);
|
return window['go']['main']['App']['IcomSetPreamp'](arg1);
|
||||||
}
|
}
|
||||||
@@ -702,10 +770,22 @@ export function IcomSetRFPower(arg1) {
|
|||||||
return window['go']['main']['App']['IcomSetRFPower'](arg1);
|
return window['go']['main']['App']['IcomSetRFPower'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IcomSetRIT(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetRIT'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetRITOn(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetRITOn'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function IcomSetScope(arg1) {
|
export function IcomSetScope(arg1) {
|
||||||
return window['go']['main']['App']['IcomSetScope'](arg1);
|
return window['go']['main']['App']['IcomSetScope'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IcomSetScopeEdges(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['IcomSetScopeEdges'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function IcomSetScopeMode(arg1) {
|
export function IcomSetScopeMode(arg1) {
|
||||||
return window['go']['main']['App']['IcomSetScopeMode'](arg1);
|
return window['go']['main']['App']['IcomSetScopeMode'](arg1);
|
||||||
}
|
}
|
||||||
@@ -714,6 +794,26 @@ export function IcomSetSplit(arg1) {
|
|||||||
return window['go']['main']['App']['IcomSetSplit'](arg1);
|
return window['go']['main']['App']['IcomSetSplit'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IcomSetSquelch(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetSquelch'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetVOX(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetVOX'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetVOXGain(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetVOXGain'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetXITOn(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetXITOn'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomStopCW() {
|
||||||
|
return window['go']['main']['App']['IcomStopCW']();
|
||||||
|
}
|
||||||
|
|
||||||
export function IcomTune() {
|
export function IcomTune() {
|
||||||
return window['go']['main']['App']['IcomTune']();
|
return window['go']['main']['App']['IcomTune']();
|
||||||
}
|
}
|
||||||
@@ -1038,6 +1138,10 @@ export function ResetDatabaseToDefault() {
|
|||||||
return window['go']['main']['App']['ResetDatabaseToDefault']();
|
return window['go']['main']['App']['ResetDatabaseToDefault']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RestartApp() {
|
||||||
|
return window['go']['main']['App']['RestartApp']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RestartQSORecorder() {
|
export function RestartQSORecorder() {
|
||||||
return window['go']['main']['App']['RestartQSORecorder']();
|
return window['go']['main']['App']['RestartQSORecorder']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ export namespace award {
|
|||||||
valid_from?: string;
|
valid_from?: string;
|
||||||
valid_to?: string;
|
valid_to?: string;
|
||||||
alias?: string;
|
alias?: string;
|
||||||
|
ref_display?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
field: string;
|
field: string;
|
||||||
match_by?: string;
|
match_by?: string;
|
||||||
@@ -289,6 +290,7 @@ export namespace award {
|
|||||||
this.valid_from = source["valid_from"];
|
this.valid_from = source["valid_from"];
|
||||||
this.valid_to = source["valid_to"];
|
this.valid_to = source["valid_to"];
|
||||||
this.alias = source["alias"];
|
this.alias = source["alias"];
|
||||||
|
this.ref_display = source["ref_display"];
|
||||||
this.type = source["type"];
|
this.type = source["type"];
|
||||||
this.field = source["field"];
|
this.field = source["field"];
|
||||||
this.match_by = source["match_by"];
|
this.match_by = source["match_by"];
|
||||||
@@ -541,9 +543,34 @@ export namespace cat {
|
|||||||
this.callsign = source["callsign"];
|
this.callsign = source["callsign"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class FlexSliceInfo {
|
||||||
|
index: number;
|
||||||
|
letter: string;
|
||||||
|
freq_hz: number;
|
||||||
|
mode?: string;
|
||||||
|
band?: string;
|
||||||
|
active: boolean;
|
||||||
|
tx: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new FlexSliceInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.index = source["index"];
|
||||||
|
this.letter = source["letter"];
|
||||||
|
this.freq_hz = source["freq_hz"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.active = source["active"];
|
||||||
|
this.tx = source["tx"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class FlexTXState {
|
export class FlexTXState {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
slices?: FlexSliceInfo[];
|
||||||
rf_power: number;
|
rf_power: number;
|
||||||
tune_power: number;
|
tune_power: number;
|
||||||
tune: boolean;
|
tune: boolean;
|
||||||
@@ -600,6 +627,7 @@ export namespace cat {
|
|||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.available = source["available"];
|
this.available = source["available"];
|
||||||
this.model = source["model"];
|
this.model = source["model"];
|
||||||
|
this.slices = this.convertValues(source["slices"], FlexSliceInfo);
|
||||||
this.rf_power = source["rf_power"];
|
this.rf_power = source["rf_power"];
|
||||||
this.tune_power = source["tune_power"];
|
this.tune_power = source["tune_power"];
|
||||||
this.tune = source["tune"];
|
this.tune = source["tune"];
|
||||||
@@ -676,6 +704,11 @@ export namespace cat {
|
|||||||
s_meter: number;
|
s_meter: number;
|
||||||
power_meter: number;
|
power_meter: number;
|
||||||
swr_meter: number;
|
swr_meter: number;
|
||||||
|
rit_hz: number;
|
||||||
|
rit_on: boolean;
|
||||||
|
xit_on: boolean;
|
||||||
|
key_speed_wpm: number;
|
||||||
|
break_in: number;
|
||||||
rf_power: number;
|
rf_power: number;
|
||||||
mic_gain: number;
|
mic_gain: number;
|
||||||
af_gain: number;
|
af_gain: number;
|
||||||
@@ -685,10 +718,24 @@ export namespace cat {
|
|||||||
nr: boolean;
|
nr: boolean;
|
||||||
nr_level: number;
|
nr_level: number;
|
||||||
anf: boolean;
|
anf: boolean;
|
||||||
|
apf: boolean;
|
||||||
agc?: string;
|
agc?: string;
|
||||||
preamp: number;
|
preamp: number;
|
||||||
att: number;
|
att: number;
|
||||||
filter: number;
|
filter: number;
|
||||||
|
antenna: number;
|
||||||
|
pbt_inner: number;
|
||||||
|
pbt_outer: number;
|
||||||
|
manual_notch: boolean;
|
||||||
|
notch_pos: number;
|
||||||
|
squelch: number;
|
||||||
|
comp: boolean;
|
||||||
|
comp_level: number;
|
||||||
|
monitor: boolean;
|
||||||
|
mon_level: number;
|
||||||
|
vox: boolean;
|
||||||
|
vox_gain: number;
|
||||||
|
anti_vox: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new IcomTXState(source);
|
return new IcomTXState(source);
|
||||||
@@ -704,6 +751,11 @@ export namespace cat {
|
|||||||
this.s_meter = source["s_meter"];
|
this.s_meter = source["s_meter"];
|
||||||
this.power_meter = source["power_meter"];
|
this.power_meter = source["power_meter"];
|
||||||
this.swr_meter = source["swr_meter"];
|
this.swr_meter = source["swr_meter"];
|
||||||
|
this.rit_hz = source["rit_hz"];
|
||||||
|
this.rit_on = source["rit_on"];
|
||||||
|
this.xit_on = source["xit_on"];
|
||||||
|
this.key_speed_wpm = source["key_speed_wpm"];
|
||||||
|
this.break_in = source["break_in"];
|
||||||
this.rf_power = source["rf_power"];
|
this.rf_power = source["rf_power"];
|
||||||
this.mic_gain = source["mic_gain"];
|
this.mic_gain = source["mic_gain"];
|
||||||
this.af_gain = source["af_gain"];
|
this.af_gain = source["af_gain"];
|
||||||
@@ -713,10 +765,24 @@ export namespace cat {
|
|||||||
this.nr = source["nr"];
|
this.nr = source["nr"];
|
||||||
this.nr_level = source["nr_level"];
|
this.nr_level = source["nr_level"];
|
||||||
this.anf = source["anf"];
|
this.anf = source["anf"];
|
||||||
|
this.apf = source["apf"];
|
||||||
this.agc = source["agc"];
|
this.agc = source["agc"];
|
||||||
this.preamp = source["preamp"];
|
this.preamp = source["preamp"];
|
||||||
this.att = source["att"];
|
this.att = source["att"];
|
||||||
this.filter = source["filter"];
|
this.filter = source["filter"];
|
||||||
|
this.antenna = source["antenna"];
|
||||||
|
this.pbt_inner = source["pbt_inner"];
|
||||||
|
this.pbt_outer = source["pbt_outer"];
|
||||||
|
this.manual_notch = source["manual_notch"];
|
||||||
|
this.notch_pos = source["notch_pos"];
|
||||||
|
this.squelch = source["squelch"];
|
||||||
|
this.comp = source["comp"];
|
||||||
|
this.comp_level = source["comp_level"];
|
||||||
|
this.monitor = source["monitor"];
|
||||||
|
this.mon_level = source["mon_level"];
|
||||||
|
this.vox = source["vox"];
|
||||||
|
this.vox_gain = source["vox_gain"];
|
||||||
|
this.anti_vox = source["anti_vox"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class RigState {
|
export class RigState {
|
||||||
@@ -1079,6 +1145,7 @@ export namespace main {
|
|||||||
export class AntGeniusSettings {
|
export class AntGeniusSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
host: string;
|
host: string;
|
||||||
|
password: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new AntGeniusSettings(source);
|
return new AntGeniusSettings(source);
|
||||||
@@ -1088,6 +1155,7 @@ export namespace main {
|
|||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
this.host = source["host"];
|
this.host = source["host"];
|
||||||
|
this.password = source["password"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class AudioSettings {
|
export class AudioSettings {
|
||||||
@@ -1278,6 +1346,9 @@ export namespace main {
|
|||||||
icom_port: string;
|
icom_port: string;
|
||||||
icom_baud: number;
|
icom_baud: number;
|
||||||
icom_addr: number;
|
icom_addr: number;
|
||||||
|
icom_net_host: string;
|
||||||
|
icom_net_user: string;
|
||||||
|
icom_net_pass: string;
|
||||||
tci_host: string;
|
tci_host: string;
|
||||||
tci_port: number;
|
tci_port: number;
|
||||||
tci_spots: boolean;
|
tci_spots: boolean;
|
||||||
@@ -1300,6 +1371,9 @@ export namespace main {
|
|||||||
this.icom_port = source["icom_port"];
|
this.icom_port = source["icom_port"];
|
||||||
this.icom_baud = source["icom_baud"];
|
this.icom_baud = source["icom_baud"];
|
||||||
this.icom_addr = source["icom_addr"];
|
this.icom_addr = source["icom_addr"];
|
||||||
|
this.icom_net_host = source["icom_net_host"];
|
||||||
|
this.icom_net_user = source["icom_net_user"];
|
||||||
|
this.icom_net_pass = source["icom_net_pass"];
|
||||||
this.tci_host = source["tci_host"];
|
this.tci_host = source["tci_host"];
|
||||||
this.tci_port = source["tci_port"];
|
this.tci_port = source["tci_port"];
|
||||||
this.tci_spots = source["tci_spots"];
|
this.tci_spots = source["tci_spots"];
|
||||||
@@ -2245,6 +2319,14 @@ export namespace netctl {
|
|||||||
dxcc?: number;
|
dxcc?: number;
|
||||||
itu?: number;
|
itu?: number;
|
||||||
cq?: number;
|
cq?: number;
|
||||||
|
grid?: string;
|
||||||
|
address?: string;
|
||||||
|
state?: string;
|
||||||
|
cnty?: string;
|
||||||
|
cont?: string;
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
|
email?: string;
|
||||||
groups?: string;
|
groups?: string;
|
||||||
sig?: string;
|
sig?: string;
|
||||||
sig_info?: string;
|
sig_info?: string;
|
||||||
@@ -2262,6 +2344,14 @@ export namespace netctl {
|
|||||||
this.dxcc = source["dxcc"];
|
this.dxcc = source["dxcc"];
|
||||||
this.itu = source["itu"];
|
this.itu = source["itu"];
|
||||||
this.cq = source["cq"];
|
this.cq = source["cq"];
|
||||||
|
this.grid = source["grid"];
|
||||||
|
this.address = source["address"];
|
||||||
|
this.state = source["state"];
|
||||||
|
this.cnty = source["cnty"];
|
||||||
|
this.cont = source["cont"];
|
||||||
|
this.lat = source["lat"];
|
||||||
|
this.lon = source["lon"];
|
||||||
|
this.email = source["email"];
|
||||||
this.groups = source["groups"];
|
this.groups = source["groups"];
|
||||||
this.sig = source["sig"];
|
this.sig = source["sig"];
|
||||||
this.sig_info = source["sig_info"];
|
this.sig_info = source["sig_info"];
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -49,10 +52,16 @@ type Status struct {
|
|||||||
type Client struct {
|
type Client struct {
|
||||||
host string
|
host string
|
||||||
port int
|
port int
|
||||||
|
password string // remote-access password; sent as "login <pw>" when the device requires AUTH
|
||||||
|
|
||||||
mu sync.Mutex // guards conn + writes
|
mu sync.Mutex // guards conn + writes
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
|
|
||||||
|
// Auth handshake state (touched only on the runLoop/readLoop goroutine).
|
||||||
|
awaitingAuth bool
|
||||||
|
authTries int
|
||||||
|
ready atomic.Bool // init commands sent → keepalive may run
|
||||||
|
|
||||||
statusMu sync.RWMutex
|
statusMu sync.RWMutex
|
||||||
status Status
|
status Status
|
||||||
antennas map[int]string // index → name (rebuilt into status.Antennas)
|
antennas map[int]string // index → name (rebuilt into status.Antennas)
|
||||||
@@ -61,13 +70,14 @@ type Client struct {
|
|||||||
running bool
|
running bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(host string, port int) *Client {
|
func New(host string, port int, password string) *Client {
|
||||||
if port <= 0 || port > 65535 {
|
if port <= 0 || port > 65535 {
|
||||||
port = defaultPort
|
port = defaultPort
|
||||||
}
|
}
|
||||||
return &Client{
|
return &Client{
|
||||||
host: host,
|
host: host,
|
||||||
port: port,
|
port: port,
|
||||||
|
password: strings.TrimSpace(password),
|
||||||
stop: make(chan struct{}),
|
stop: make(chan struct{}),
|
||||||
antennas: map[int]string{},
|
antennas: map[int]string{},
|
||||||
status: Status{Host: host},
|
status: Status{Host: host},
|
||||||
@@ -146,13 +156,13 @@ func (c *Client) runLoop() {
|
|||||||
c.conn = conn
|
c.conn = conn
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host })
|
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host })
|
||||||
|
applog.Printf("antgenius: TCP connected %s → %s", conn.LocalAddr(), conn.RemoteAddr())
|
||||||
|
|
||||||
// Subscribe to live updates and pull the initial state. Command set and
|
// Auth + init are driven by the banner (handleLine): on "V… AG AUTH" with a
|
||||||
// order mirror a known-working Node-RED v4 client (WA9WUD).
|
// password we send "auth code=<pw>" and only send the subscribe/get commands
|
||||||
_ = c.send("antenna list")
|
// once it's accepted (retrying on FF); without AUTH they go out immediately.
|
||||||
_ = c.send("sub port all")
|
c.awaitingAuth, c.authTries = false, 0
|
||||||
_ = c.send("port get 1")
|
c.ready.Store(false)
|
||||||
_ = c.send("port get 2")
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go c.keepalive(conn, done)
|
go c.keepalive(conn, done)
|
||||||
@@ -189,23 +199,51 @@ func (c *Client) keepalive(conn net.Conn, done chan struct{}) {
|
|||||||
case <-c.stop:
|
case <-c.stop:
|
||||||
return
|
return
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
|
if !c.ready.Load() {
|
||||||
|
continue // not authenticated / subscribed yet
|
||||||
|
}
|
||||||
_ = c.send("port get 1")
|
_ = c.send("port get 1")
|
||||||
_ = c.send("port get 2")
|
_ = c.send("port get 2")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendInit subscribes to live updates and pulls the initial state. Called once
|
||||||
|
// the link is ready (authenticated, or immediately when no AUTH is required).
|
||||||
|
// Command set/order mirror a known-working Node-RED v4 client (WA9WUD).
|
||||||
|
func (c *Client) sendInit() {
|
||||||
|
c.ready.Store(true)
|
||||||
|
for _, cmd := range []string{"antenna list", "sub port all", "port get 1", "port get 2"} {
|
||||||
|
if err := c.send(cmd); err != nil {
|
||||||
|
applog.Printf("antgenius: init send %q failed: %v", cmd, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) readLoop(conn net.Conn) error {
|
func (c *Client) readLoop(conn net.Conn) error {
|
||||||
r := bufio.NewReader(conn)
|
r := bufio.NewReader(conn)
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
start := time.Now()
|
||||||
|
lines, bytesRx := 0, 0
|
||||||
for {
|
for {
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(readIdleTimeout))
|
_ = conn.SetReadDeadline(time.Now().Add(readIdleTimeout))
|
||||||
b, err := r.ReadByte()
|
b, err := r.ReadByte()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Log where the link died: 0 lines after connect points at the device
|
||||||
|
// refusing/closing (e.g. a client-slot limit or source-IP rejection —
|
||||||
|
// common when reaching it remotely); an EOF after the banner/replies
|
||||||
|
// points at something later in the exchange.
|
||||||
|
applog.Printf("antgenius: read ended after %s — %d line(s), %d byte(s): %v",
|
||||||
|
time.Since(start).Round(time.Millisecond), lines, bytesRx, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
bytesRx++
|
||||||
if b == '\r' || b == '\n' {
|
if b == '\r' || b == '\n' {
|
||||||
if sb.Len() > 0 {
|
if sb.Len() > 0 {
|
||||||
|
lines++
|
||||||
|
if lines <= 10 {
|
||||||
|
applog.Printf("antgenius: rx[%d] %q", lines, sb.String())
|
||||||
|
}
|
||||||
c.handleLine(sb.String())
|
c.handleLine(sb.String())
|
||||||
sb.Reset()
|
sb.Reset()
|
||||||
}
|
}
|
||||||
@@ -236,16 +274,31 @@ func (c *Client) handleLine(line string) {
|
|||||||
if line == "" {
|
if line == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Banner like "V4.1.16 AG" — just confirms the link is up.
|
// Banner: "V4.1.16 AG" (LAN) or "V4.1.16 AG AUTH" (remote). It drives what we
|
||||||
|
// send next — authenticate first when AUTH is announced, else go straight to
|
||||||
|
// the subscribe/get commands.
|
||||||
if line[0] == 'V' && strings.Contains(line, "AG") {
|
if line[0] == 'V' && strings.Contains(line, "AG") {
|
||||||
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = "" })
|
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = "" })
|
||||||
|
if strings.Contains(line, "AUTH") && c.password != "" {
|
||||||
|
c.awaitingAuth, c.authTries = true, 1
|
||||||
|
applog.Printf("antgenius: AUTH required — authenticating")
|
||||||
|
_ = c.send("auth code=" + c.password)
|
||||||
|
} else {
|
||||||
|
if strings.Contains(line, "AUTH") {
|
||||||
|
applog.Printf("antgenius: device requires AUTH but no remote password set (Settings → Antenna Genius)")
|
||||||
|
}
|
||||||
|
c.sendInit()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// R<seq>|<hex>|<message> or S<seq>|<message>
|
// R<seq>|<hex>|<message> or S<seq>|<message>
|
||||||
var msg string
|
var hexCode, msg string
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(line, "R"):
|
case strings.HasPrefix(line, "R"):
|
||||||
p := strings.SplitN(line, "|", 3)
|
p := strings.SplitN(line, "|", 3)
|
||||||
|
if len(p) >= 2 {
|
||||||
|
hexCode = p[1]
|
||||||
|
}
|
||||||
if len(p) == 3 {
|
if len(p) == 3 {
|
||||||
msg = p[2]
|
msg = p[2]
|
||||||
}
|
}
|
||||||
@@ -256,6 +309,25 @@ func (c *Client) handleLine(line string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
msg = strings.TrimSpace(msg)
|
msg = strings.TrimSpace(msg)
|
||||||
|
// Auth result: the reply to "auth code=" carries an empty message; hex "0" =
|
||||||
|
// accepted. The device rejects the FIRST attempt (R|FF) and accepts a retry,
|
||||||
|
// so resend a few times before giving up.
|
||||||
|
if c.awaitingAuth && strings.HasPrefix(line, "R") && msg == "" {
|
||||||
|
switch {
|
||||||
|
case hexCode == "0":
|
||||||
|
c.awaitingAuth = false
|
||||||
|
applog.Printf("antgenius: authenticated")
|
||||||
|
c.sendInit()
|
||||||
|
case c.authTries < 4:
|
||||||
|
c.authTries++
|
||||||
|
applog.Printf("antgenius: auth rejected (R|%s|) — retry %d", hexCode, c.authTries)
|
||||||
|
_ = c.send("auth code=" + c.password)
|
||||||
|
default:
|
||||||
|
c.awaitingAuth = false
|
||||||
|
applog.Printf("antgenius: auth failed after %d tries (R|%s|) — check the remote password", c.authTries, hexCode)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(msg, "antenna "):
|
case strings.HasPrefix(msg, "antenna "):
|
||||||
c.parseAntenna(msg)
|
c.parseAntenna(msg)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package antgenius
|
|||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestHandleAntennaList(t *testing.T) {
|
func TestHandleAntennaList(t *testing.T) {
|
||||||
c := New("x", 9007)
|
c := New("x", 9007, "")
|
||||||
// Names may contain spaces — must be captured up to " tx=".
|
// Names may contain spaces — must be captured up to " tx=".
|
||||||
c.handleLine("R3|0|antenna 1 name=UB VL2.3 tx=ffff rx=ffff inband=0000")
|
c.handleLine("R3|0|antenna 1 name=UB VL2.3 tx=ffff rx=ffff inband=0000")
|
||||||
c.handleLine("R3|0|antenna 2 name=DX Commander tx=00ff rx=00ff inband=0000")
|
c.handleLine("R3|0|antenna 2 name=DX Commander tx=00ff rx=00ff inband=0000")
|
||||||
@@ -20,7 +20,7 @@ func TestHandleAntennaList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAntennaUnderscoreAndPlaceholder(t *testing.T) {
|
func TestAntennaUnderscoreAndPlaceholder(t *testing.T) {
|
||||||
c := New("x", 9007)
|
c := New("x", 9007, "")
|
||||||
c.handleLine("R3|0|antenna 1 name=Hex_Beam tx=ffff rx=ffff inband=0000") // underscore → space
|
c.handleLine("R3|0|antenna 1 name=Hex_Beam tx=ffff rx=ffff inband=0000") // underscore → space
|
||||||
c.handleLine("R3|0|antenna 4 name=Antenna_4 tx=0000 rx=0000 inband=0000") // default → filtered
|
c.handleLine("R3|0|antenna 4 name=Antenna_4 tx=0000 rx=0000 inband=0000") // default → filtered
|
||||||
c.handleLine("R3|0|antenna 5 name=antenna 5 tx=0000 rx=0000 inband=0000") // default → filtered
|
c.handleLine("R3|0|antenna 5 name=antenna 5 tx=0000 rx=0000 inband=0000") // default → filtered
|
||||||
@@ -31,7 +31,7 @@ func TestAntennaUnderscoreAndPlaceholder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlePortStatus(t *testing.T) {
|
func TestHandlePortStatus(t *testing.T) {
|
||||||
c := New("x", 9007)
|
c := New("x", 9007, "")
|
||||||
// Async push after "sub port all": active antenna is txant (fallback rxant).
|
// Async push after "sub port all": active antenna is txant (fallback rxant).
|
||||||
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=2 txant=2 inband=0 inhibit=0")
|
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=2 txant=2 inband=0 inhibit=0")
|
||||||
c.handleLine("S0|port 2 source=AUTO band=0 rxant=0 txant=0 inband=0 inhibit=0")
|
c.handleLine("S0|port 2 source=AUTO band=0 rxant=0 txant=0 inband=0 inhibit=0")
|
||||||
@@ -50,7 +50,7 @@ func TestHandlePortStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPortTxFallbackToRx(t *testing.T) {
|
func TestPortTxFallbackToRx(t *testing.T) {
|
||||||
c := New("x", 9007)
|
c := New("x", 9007, "")
|
||||||
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=3 txant=0 inband=0 inhibit=0")
|
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=3 txant=0 inband=0 inhibit=0")
|
||||||
if st := c.GetStatus(); st.PortA != 3 {
|
if st := c.GetStatus(); st.PortA != 3 {
|
||||||
t.Errorf("PortA = %d, want 3 (rx fallback when tx=0)", st.PortA)
|
t.Errorf("PortA = %d, want 3 (rx fallback when tx=0)", st.PortA)
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ type Def struct {
|
|||||||
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
|
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
|
||||||
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
|
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
|
||||||
Alias string `json:"alias,omitempty"`
|
Alias string `json:"alias,omitempty"`
|
||||||
|
// RefDisplay picks what the grid's award column shows for a match: "" or "ref"
|
||||||
|
// = the reference (e.g. WAJA "36"), "name" = the reference's description (e.g.
|
||||||
|
// the prefecture name), "both" = "ref — name". DXCC always shows the country
|
||||||
|
// name under "ref" (unchanged).
|
||||||
|
RefDisplay string `json:"ref_display,omitempty"`
|
||||||
|
|
||||||
// --- Type & matching ---
|
// --- Type & matching ---
|
||||||
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ type Backend interface {
|
|||||||
SetPTT(on bool) error
|
SetPTT(on bool) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// interruptible is an OPTIONAL backend capability: abort an in-progress Connect
|
||||||
|
// quickly. The network Icom backend's Connect blocks for up to tens of seconds
|
||||||
|
// (UDP handshake + login + waiting for the rig to boot from standby); without a
|
||||||
|
// way to interrupt it, Stop()/Start() would freeze on the poll goroutine until
|
||||||
|
// the dial gives up — which is why Settings "Save & Close" hung for ~1 min once
|
||||||
|
// the link was lost. Backends that don't implement it are simply not interrupted.
|
||||||
|
type interruptible interface {
|
||||||
|
Interrupt()
|
||||||
|
}
|
||||||
|
|
||||||
// RigState is the snapshot exchanged with the frontend.
|
// RigState is the snapshot exchanged with the frontend.
|
||||||
//
|
//
|
||||||
// FreqHz follows the ADIF FREQ convention: it is the TX frequency. When the
|
// FreqHz follows the ADIF FREQ convention: it is the TX frequency. When the
|
||||||
@@ -156,6 +166,7 @@ func (m *Manager) stopLocked() {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
stop := m.stopCh
|
stop := m.stopCh
|
||||||
done := m.doneCh
|
done := m.doneCh
|
||||||
|
b := m.backend
|
||||||
m.stopCh = nil
|
m.stopCh = nil
|
||||||
m.doneCh = nil
|
m.doneCh = nil
|
||||||
m.cmdCh = nil
|
m.cmdCh = nil
|
||||||
@@ -164,6 +175,11 @@ func (m *Manager) stopLocked() {
|
|||||||
if stop != nil {
|
if stop != nil {
|
||||||
close(stop)
|
close(stop)
|
||||||
}
|
}
|
||||||
|
// Abort any in-progress Connect so we don't block on a slow network dial
|
||||||
|
// (the poll goroutine can be tens of seconds deep in the Icom UDP handshake).
|
||||||
|
if iv, ok := b.(interruptible); ok {
|
||||||
|
iv.Interrupt()
|
||||||
|
}
|
||||||
if done != nil {
|
if done != nil {
|
||||||
<-done
|
<-done
|
||||||
}
|
}
|
||||||
@@ -229,9 +245,24 @@ func (m *Manager) SendSpot(s SpotInfo) {
|
|||||||
// FlexTXState is the FlexRadio transmit/ATU state surfaced to the dedicated
|
// FlexTXState is the FlexRadio transmit/ATU state surfaced to the dedicated
|
||||||
// FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by
|
// FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by
|
||||||
// the radio over TCP; live meters arrive over a separate UDP stream later.)
|
// the radio over TCP; live meters arrive over a separate UDP stream later.)
|
||||||
|
// FlexSliceInfo identifies one FlexRadio receiver slice (A/B/C/D…) for the
|
||||||
|
// panel, so the operator sees every slice and which one is active/TX.
|
||||||
|
type FlexSliceInfo struct {
|
||||||
|
Index int `json:"index"` // 0-based slice index
|
||||||
|
Letter string `json:"letter"` // A, B, C, D…
|
||||||
|
FreqHz int64 `json:"freq_hz"`
|
||||||
|
Mode string `json:"mode,omitempty"` // ADIF mode
|
||||||
|
Band string `json:"band,omitempty"`
|
||||||
|
Active bool `json:"active"` // the focused/operating slice
|
||||||
|
TX bool `json:"tx"` // this slice transmits
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
// them all and highlight the active one. The active slice drives everything.
|
||||||
|
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
|
||||||
@@ -323,6 +354,8 @@ type FlexController interface {
|
|||||||
SetMute(bool) error
|
SetMute(bool) error
|
||||||
SetRXAntenna(string) error
|
SetRXAntenna(string) error
|
||||||
SetTXAntenna(string) error
|
SetTXAntenna(string) error
|
||||||
|
SetActiveSlice(int) error // focus slice idx so commands target it
|
||||||
|
SetTXSlice(int) error // make slice idx the transmitter (tx=1)
|
||||||
SetSplit(bool) error
|
SetSplit(bool) error
|
||||||
SetNB(bool) error
|
SetNB(bool) error
|
||||||
SetNBLevel(int) error
|
SetNBLevel(int) error
|
||||||
@@ -382,6 +415,13 @@ type IcomTXState struct {
|
|||||||
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).
|
||||||
|
RITHz int `json:"rit_hz"` // RIT/XIT offset, signed Hz
|
||||||
|
RITOn bool `json:"rit_on"`
|
||||||
|
XITOn bool `json:"xit_on"`
|
||||||
|
// CW keyer (send messages via the rig's internal keyer, CI-V 0x17).
|
||||||
|
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
|
||||||
// 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
|
||||||
@@ -392,10 +432,27 @@ type IcomTXState struct {
|
|||||||
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)
|
||||||
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 int `json:"antenna"` // 1 | 2 (0 = unknown)
|
||||||
|
// Filter fine controls: Twin PBT + manual notch (0-100, 50 = centre).
|
||||||
|
PBTInner int `json:"pbt_inner"`
|
||||||
|
PBTOuter int `json:"pbt_outer"`
|
||||||
|
ManualNotch bool `json:"manual_notch"`
|
||||||
|
NotchPos int `json:"notch_pos"`
|
||||||
|
// TX extras.
|
||||||
|
Squelch int `json:"squelch"`
|
||||||
|
Comp bool `json:"comp"`
|
||||||
|
CompLevel int `json:"comp_level"`
|
||||||
|
Monitor bool `json:"monitor"`
|
||||||
|
MonLevel int `json:"mon_level"`
|
||||||
|
VOX bool `json:"vox"`
|
||||||
|
VOXGain int `json:"vox_gain"`
|
||||||
|
AntiVOX int `json:"anti_vox"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IcomController is an OPTIONAL backend capability (the Icom CI-V backend): the
|
// IcomController is an OPTIONAL backend capability (the Icom CI-V backend): the
|
||||||
@@ -411,6 +468,7 @@ type IcomController interface {
|
|||||||
SetNR(bool) error
|
SetNR(bool) error
|
||||||
SetNRLevel(int) error
|
SetNRLevel(int) error
|
||||||
SetANF(bool) error
|
SetANF(bool) error
|
||||||
|
SetAPF(bool) error
|
||||||
SetAGC(string) error
|
SetAGC(string) error
|
||||||
SetPreamp(int) error
|
SetPreamp(int) error
|
||||||
SetAtt(int) error
|
SetAtt(int) error
|
||||||
@@ -421,7 +479,29 @@ type IcomController interface {
|
|||||||
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)
|
||||||
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
|
||||||
|
SetRITOn(bool) error // RIT on/off
|
||||||
|
SetXITOn(bool) error // ΔTX (XIT) on/off
|
||||||
|
SendCW(string) error // key a CW message via the rig's keyer (CI-V 0x17)
|
||||||
|
StopCW() error // abort the CW message being sent
|
||||||
|
SetKeySpeed(int) error // CW keyer speed in WPM
|
||||||
|
SetBreakIn(int) error // CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||||
|
SetAntenna(int) error // 1 = ANT1, 2 = ANT2
|
||||||
|
SetPBTInner(int) error // Twin PBT inside (0-100, 50 = centre)
|
||||||
|
SetPBTOuter(int) error // Twin PBT outside (0-100, 50 = centre)
|
||||||
|
SetManualNotch(bool) error
|
||||||
|
SetNotchPos(int) error // manual-notch position (0-100, 50 = centre)
|
||||||
|
SetSquelch(int) error
|
||||||
|
SetComp(bool) error
|
||||||
|
SetCompLevel(int) error
|
||||||
|
SetMonitor(bool) error
|
||||||
|
SetMonLevel(int) error
|
||||||
|
SetVOX(bool) error
|
||||||
|
SetVOXGain(int) error
|
||||||
|
SetAntiVOX(int) error
|
||||||
|
SetPower(bool) error // turn the transceiver on/off (manual — never auto on connect)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
|
// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
|
||||||
|
|||||||
+118
-3
@@ -37,13 +37,29 @@ const (
|
|||||||
CmdPTT = 0x1C // sub 0x00 = PTT
|
CmdPTT = 0x1C // sub 0x00 = PTT
|
||||||
CmdExtra = 0x1A // sub 0x06 = data mode on modern Icoms
|
CmdExtra = 0x1A // sub 0x06 = data mode on modern Icoms
|
||||||
CmdReadID = 0x19 // sub 0x00 = rig's own CI-V address (identifies model)
|
CmdReadID = 0x19 // sub 0x00 = rig's own CI-V address (identifies model)
|
||||||
|
CmdPower = 0x18 // power on/off (sub 0x01 = on, 0x00 = off; on needs an FE wake preamble)
|
||||||
|
|
||||||
|
CmdAnt = 0x12 // antenna selector (sub 0x00 = ANT1, 0x01 = ANT2; read = no sub)
|
||||||
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
|
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
|
||||||
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
|
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
|
||||||
CmdMeter = 0x15 // meters (sub + 2 BCD bytes, 0000-0255): S-meter/Po/SWR
|
CmdMeter = 0x15 // meters (sub + 2 BCD bytes, 0000-0255): S-meter/Po/SWR
|
||||||
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
|
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
|
||||||
CmdATU = 0x1C // sub 0x01 = antenna tuner (0x00 off, 0x01 through, 0x02 tune)
|
CmdATU = 0x1C // sub 0x01 = antenna tuner (0x00 off, 0x01 through, 0x02 tune)
|
||||||
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
|
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
|
||||||
|
CmdRIT = 0x21 // RIT/ΔTX: sub 0x00 offset freq, 0x01 RIT on/off, 0x02 ΔTX(XIT) on/off
|
||||||
|
CmdSendCW = 0x17 // send a CW message (ASCII, ≤30 chars) via the rig's keyer; data 0xFF = stop
|
||||||
|
|
||||||
|
SubLevelKeySpeed = 0x0C // CmdLevel: CW keying speed (0-255 → KeyMinWPM..KeyMaxWPM)
|
||||||
|
|
||||||
|
// CW keyer speed range for the KEY SPEED level (IC-7610: 6-48 WPM).
|
||||||
|
KeyMinWPM = 6
|
||||||
|
KeyMaxWPM = 48
|
||||||
|
|
||||||
|
StopCWByte = 0xFF // 0x17 data byte that stops an in-progress CW message
|
||||||
|
|
||||||
|
SubRITFreq = 0x00 // RIT/ΔTX offset: 2 BCD bytes (LE, 0-9999) + sign byte (00 +, 01 -)
|
||||||
|
SubRITOn = 0x01 // RIT on/off (00/01)
|
||||||
|
SubXITOn = 0x02 // ΔTX (XIT) on/off (00/01)
|
||||||
|
|
||||||
SubDataMode = 0x06
|
SubDataMode = 0x06
|
||||||
SubPTT = 0x00
|
SubPTT = 0x00
|
||||||
@@ -53,8 +69,16 @@ const (
|
|||||||
// CmdLevel sub-commands.
|
// CmdLevel sub-commands.
|
||||||
SubLevelAF = 0x01 // AF (volume)
|
SubLevelAF = 0x01 // AF (volume)
|
||||||
SubLevelRF = 0x02 // RF gain
|
SubLevelRF = 0x02 // RF gain
|
||||||
|
SubLevelSQL = 0x03 // squelch level
|
||||||
|
SubLevelPBTIn = 0x07 // Twin PBT (inside) — 0-255, 128 = centre
|
||||||
|
SubLevelPBTOut = 0x08 // Twin PBT (outside) — 0-255, 128 = centre
|
||||||
SubLevelNR = 0x06 // noise-reduction depth
|
SubLevelNR = 0x06 // noise-reduction depth
|
||||||
|
SubLevelNotch = 0x0D // manual-notch position — 0-255, 128 = centre
|
||||||
|
SubLevelComp = 0x0E // speech-compressor level
|
||||||
SubLevelNB = 0x12 // noise-blanker depth
|
SubLevelNB = 0x12 // noise-blanker depth
|
||||||
|
SubLevelMon = 0x15 // monitor gain
|
||||||
|
SubLevelVOXGain = 0x16 // VOX gain
|
||||||
|
SubLevelAntiVOX = 0x17 // anti-VOX level
|
||||||
SubLevelRFPower = 0x0A // TX RF output power
|
SubLevelRFPower = 0x0A // TX RF output power
|
||||||
SubLevelMic = 0x0B // mic gain
|
SubLevelMic = 0x0B // mic gain
|
||||||
|
|
||||||
@@ -70,9 +94,10 @@ const (
|
|||||||
SubScopeData = 0x00 // waveform data frame (divided across several frames)
|
SubScopeData = 0x00 // waveform data frame (divided across several frames)
|
||||||
SubScopeOnOff = 0x10 // turn the scope display itself on/off (00/01)
|
SubScopeOnOff = 0x10 // turn the scope display itself on/off (00/01)
|
||||||
SubScopeOn = 0x11 // enable/disable waveform data output over CI-V (00/01)
|
SubScopeOn = 0x11 // enable/disable waveform data output over CI-V (00/01)
|
||||||
SubScopeMode = 0x14 // center/fixed mode (0=center, 1=fixed) — VERIFY on rig
|
SubScopeMode = 0x14 // center/fixed mode (0=center, 1=fixed)
|
||||||
SubScopeSpan = 0x15 // span in center mode — VERIFY on rig
|
SubScopeSpan = 0x15 // span in center mode (±span/2 as 5 LE-BCD)
|
||||||
SubScopeEdge = 0x16 // fixed-mode edge frequencies — VERIFY on rig
|
SubScopeEdge = 0x16 // fixed-mode ACTIVE edge set 1-4 (vfo + set#)
|
||||||
|
SubScopeFixEdge = 0x1e // fixed-mode edge FREQUENCIES: [range][set#][low 5-BCD][high 5-BCD]
|
||||||
|
|
||||||
// CmdSwitch sub-commands.
|
// CmdSwitch sub-commands.
|
||||||
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
|
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
|
||||||
@@ -80,6 +105,19 @@ const (
|
|||||||
SubSwNB = 0x22 // noise blanker on/off
|
SubSwNB = 0x22 // noise blanker on/off
|
||||||
SubSwNR = 0x40 // noise reduction on/off
|
SubSwNR = 0x40 // noise reduction on/off
|
||||||
SubSwANF = 0x41 // auto-notch on/off
|
SubSwANF = 0x41 // auto-notch on/off
|
||||||
|
SubSwComp = 0x44 // speech compressor on/off
|
||||||
|
SubSwMon = 0x45 // monitor on/off
|
||||||
|
SubSwVOX = 0x46 // VOX on/off
|
||||||
|
SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX)
|
||||||
|
SubSwMN = 0x48 // manual notch on/off
|
||||||
|
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
|
||||||
|
)
|
||||||
|
|
||||||
|
// CW break-in modes (CmdSwitch 0x47).
|
||||||
|
const (
|
||||||
|
BreakInOff = 0
|
||||||
|
BreakInSemi = 1
|
||||||
|
BreakInFull = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
// Icom mode codes (used by CmdReadMode / CmdSetMode).
|
// Icom mode codes (used by CmdReadMode / CmdSetMode).
|
||||||
@@ -154,6 +192,83 @@ func BCDToLevel(b []byte) int {
|
|||||||
return int(b[0])*100 + int(b[1]>>4)*10 + int(b[1]&0x0F)
|
return int(b[0])*100 + int(b[1]>>4)*10 + int(b[1]&0x0F)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RITToBCD encodes a RIT/ΔTX offset (Hz, −9999..9999) as the 3 bytes CI-V
|
||||||
|
// command 0x21 0x00 uses: 2 little-endian BCD bytes of the magnitude followed by
|
||||||
|
// a sign byte (0x00 positive, 0x01 negative).
|
||||||
|
func RITToBCD(hz int) []byte {
|
||||||
|
neg := hz < 0
|
||||||
|
if neg {
|
||||||
|
hz = -hz
|
||||||
|
}
|
||||||
|
if hz > 9999 {
|
||||||
|
hz = 9999
|
||||||
|
}
|
||||||
|
lo := byte(hz%10 | (hz/10%10)<<4)
|
||||||
|
hi := byte(hz/100%10 | (hz/1000%10)<<4)
|
||||||
|
sign := byte(0)
|
||||||
|
if neg {
|
||||||
|
sign = 1
|
||||||
|
}
|
||||||
|
return []byte{lo, hi, sign}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BCDToRIT decodes the 3 offset bytes of a 0x21 0x00 response back to signed Hz.
|
||||||
|
func BCDToRIT(b []byte) int {
|
||||||
|
if len(b) < 3 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v := int(b[0]&0x0F) + int(b[0]>>4)*10 + int(b[1]&0x0F)*100 + int(b[1]>>4)*1000
|
||||||
|
if b[2] != 0 {
|
||||||
|
return -v
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// WPMToKeyLevel maps a CW speed in words-per-minute to the 0-255 value the KEY
|
||||||
|
// SPEED level (CmdLevel 0x0C) expects, linear across KeyMinWPM..KeyMaxWPM.
|
||||||
|
func WPMToKeyLevel(wpm int) int {
|
||||||
|
if wpm < KeyMinWPM {
|
||||||
|
wpm = KeyMinWPM
|
||||||
|
}
|
||||||
|
if wpm > KeyMaxWPM {
|
||||||
|
wpm = KeyMaxWPM
|
||||||
|
}
|
||||||
|
return (wpm - KeyMinWPM) * 255 / (KeyMaxWPM - KeyMinWPM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyLevelToWPM is the inverse of WPMToKeyLevel (0-255 → WPM).
|
||||||
|
func KeyLevelToWPM(v int) int {
|
||||||
|
if v < 0 {
|
||||||
|
v = 0
|
||||||
|
}
|
||||||
|
if v > 255 {
|
||||||
|
v = 255
|
||||||
|
}
|
||||||
|
return KeyMinWPM + (v*(KeyMaxWPM-KeyMinWPM)+127)/255
|
||||||
|
}
|
||||||
|
|
||||||
|
// CWText is the set of characters the rig's keyer accepts (command 0x17).
|
||||||
|
// Everything else is dropped. Space keys a word gap.
|
||||||
|
const CWText = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 /?.,-=+@:"
|
||||||
|
|
||||||
|
// FilterCW upper-cases text and keeps only keyer-legal characters.
|
||||||
|
func FilterCW(text string) string {
|
||||||
|
out := make([]byte, 0, len(text))
|
||||||
|
for i := 0; i < len(text); i++ {
|
||||||
|
c := text[i]
|
||||||
|
if c >= 'a' && c <= 'z' {
|
||||||
|
c -= 32
|
||||||
|
}
|
||||||
|
for j := 0; j < len(CWText); j++ {
|
||||||
|
if CWText[j] == c {
|
||||||
|
out = append(out, c)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
// ByteToBCD / BCDToByte handle a single packed-BCD byte (used by the
|
// ByteToBCD / BCDToByte handle a single packed-BCD byte (used by the
|
||||||
// attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…).
|
// attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…).
|
||||||
func ByteToBCD(v int) byte {
|
func ByteToBCD(v int) byte {
|
||||||
|
|||||||
+201
-101
@@ -4,6 +4,7 @@ package cat
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
@@ -27,6 +28,7 @@ type Flex struct {
|
|||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
|
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
|
||||||
@@ -171,7 +173,19 @@ func (f *Flex) Connect() error {
|
|||||||
if host == "" {
|
if host == "" {
|
||||||
return fmt.Errorf("flex: no radio IP configured")
|
return fmt.Errorf("flex: no radio IP configured")
|
||||||
}
|
}
|
||||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), 5*time.Second)
|
// Cancellable dial: Interrupt() (called by Stop/Start) aborts it at once so a
|
||||||
|
// dead radio's 5 s dial timeout doesn't make Stop / Settings "Save & Close"
|
||||||
|
// wait several seconds for the poll goroutine to give up.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
f.mu.Lock()
|
||||||
|
f.dialCancel = cancel
|
||||||
|
f.mu.Unlock()
|
||||||
|
var d net.Dialer
|
||||||
|
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||||
|
cancel()
|
||||||
|
f.mu.Lock()
|
||||||
|
f.dialCancel = nil
|
||||||
|
f.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("flex: connect %s:%d: %w", host, port, err)
|
return fmt.Errorf("flex: connect %s:%d: %w", host, port, err)
|
||||||
}
|
}
|
||||||
@@ -224,6 +238,23 @@ func (f *Flex) Disconnect() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Interrupt aborts an in-flight Connect dial so Stop()/Start() (Settings
|
||||||
|
// "Save & Close", CAT backend switch) don't block on a dead radio's 5 s dial
|
||||||
|
// timeout. Satisfies the Manager's optional interruptible interface. Safe to call
|
||||||
|
// anytime and from another goroutine; a no-op when not dialing.
|
||||||
|
func (f *Flex) Interrupt() {
|
||||||
|
f.mu.Lock()
|
||||||
|
cancel := f.dialCancel
|
||||||
|
c := f.conn
|
||||||
|
f.mu.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
if c != nil {
|
||||||
|
_ = c.Close() // unblock the reader if we're already past the dial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// send writes a sequenced command (C<seq>|<cmd>) to the radio and returns the
|
// send writes a sequenced command (C<seq>|<cmd>) to the radio and returns the
|
||||||
// sequence number (so the caller can match the R<seq> response, e.g. to learn a
|
// sequence number (so the caller can match the R<seq> response, e.g. to learn a
|
||||||
// new spot's index). Returns 0 when not connected. Best effort.
|
// new spot's index). Returns 0 when not connected. Best effort.
|
||||||
@@ -743,21 +774,19 @@ func (f *Flex) ReadState() (RigState, error) {
|
|||||||
if !f.gotHandle {
|
if !f.gotHandle {
|
||||||
return st, nil // connected TCP but radio hasn't handshaked yet
|
return st, nil // connected TCP but radio hasn't handshaked yet
|
||||||
}
|
}
|
||||||
rx, tx := f.pickSlicesLocked()
|
main, rxS, txSplit := f.operatingLocked()
|
||||||
if rx == nil && tx == nil {
|
if main == nil {
|
||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
if tx == nil {
|
// Main frequency/mode = the ACTIVE slice (what the operator is on). Only a
|
||||||
tx = rx
|
// genuine same-band split adds a separate TX freq; then ADIF convention wants
|
||||||
}
|
// FreqHz = TX and RxFreqHz = RX.
|
||||||
if rx == nil {
|
st.FreqHz = main.freqHz
|
||||||
rx = tx
|
st.Mode = flexModeToADIF(main.mode)
|
||||||
}
|
if rxS != nil && txSplit != nil {
|
||||||
st.FreqHz = tx.freqHz
|
|
||||||
st.Mode = flexModeToADIF(tx.mode)
|
|
||||||
if rx.freqHz != tx.freqHz {
|
|
||||||
st.Split = true
|
st.Split = true
|
||||||
st.RxFreqHz = rx.freqHz
|
st.RxFreqHz = rxS.freqHz
|
||||||
|
st.FreqHz = txSplit.freqHz
|
||||||
}
|
}
|
||||||
sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
|
sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
|
||||||
if sig != f.lastStateSig {
|
if sig != f.lastStateSig {
|
||||||
@@ -767,76 +796,130 @@ func (f *Flex) ReadState() (RigState, error) {
|
|||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickSlicesLocked chooses the TX and RX slices among in-use slices. TX is the
|
// mainSliceLocked is the operator's slice: the ACTIVE (focused) in-use slice, or
|
||||||
// slice flagged tx=1. RX is the slice you actually receive on — the NON-TX slice
|
// the lowest-indexed in-use slice when none is flagged active. EVERYTHING the
|
||||||
// (preferring the active/focused one), NOT simply the active slice: tuning the
|
// user does — freq/mode display, RX DSP, tuning, mode changes, spot clicks —
|
||||||
// TX slice makes it the active/focused slice, which would otherwise collapse RX
|
// follows this slice, so a second independent slice (e.g. monitoring another
|
||||||
// onto TX and hide the split. Caller holds f.mu.
|
// band) never hijacks the main frequency. Returns (-1, nil) when no slice is in
|
||||||
func (f *Flex) pickSlicesLocked() (rx, tx *flexSlice) {
|
// use. Caller holds f.mu.
|
||||||
idxs := make([]int, 0, len(f.slices))
|
func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
||||||
for i, s := range f.slices {
|
best, bestS := 1<<30, (*flexSlice)(nil)
|
||||||
if s.inUse {
|
|
||||||
idxs = append(idxs, i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Ints(idxs)
|
|
||||||
var active, txS, nonTx, first *flexSlice
|
|
||||||
for _, i := range idxs {
|
|
||||||
s := f.slices[i]
|
|
||||||
if first == nil {
|
|
||||||
first = s
|
|
||||||
}
|
|
||||||
if s.active {
|
|
||||||
active = s
|
|
||||||
}
|
|
||||||
if s.tx {
|
|
||||||
txS = s
|
|
||||||
} else if nonTx == nil {
|
|
||||||
nonTx = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tx = txS
|
|
||||||
if tx == nil {
|
|
||||||
if active != nil {
|
|
||||||
tx = active
|
|
||||||
} else {
|
|
||||||
tx = first
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// RX = the receive slice: the active one if it isn't the TX slice, else the
|
|
||||||
// first non-TX slice; fall back to TX (simplex) when there's only one slice.
|
|
||||||
switch {
|
|
||||||
case active != nil && active != tx:
|
|
||||||
rx = active
|
|
||||||
case nonTx != nil:
|
|
||||||
rx = nonTx
|
|
||||||
default:
|
|
||||||
rx = tx
|
|
||||||
}
|
|
||||||
return rx, tx
|
|
||||||
}
|
|
||||||
|
|
||||||
// activeSliceIndexLocked returns the slice index to send commands to (the active
|
|
||||||
// slice, else the lowest in-use index, else 0). Caller holds f.mu.
|
|
||||||
func (f *Flex) activeSliceIndexLocked() int {
|
|
||||||
best, found := 1<<30, false
|
|
||||||
for idx, s := range f.slices {
|
for idx, s := range f.slices {
|
||||||
if !s.inUse {
|
if !s.inUse {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if s.active {
|
if s.active {
|
||||||
return idx
|
return idx, s
|
||||||
}
|
}
|
||||||
if idx < best {
|
if idx < best {
|
||||||
best, found = idx, true
|
best, bestS = idx, s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if found {
|
if bestS != nil {
|
||||||
return best
|
return best, bestS
|
||||||
|
}
|
||||||
|
return -1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeSliceIndexLocked returns the slice index to send commands to (the main
|
||||||
|
// slice, else 0). Caller holds f.mu.
|
||||||
|
func (f *Flex) activeSliceIndexLocked() int {
|
||||||
|
if idx, _ := f.mainSliceLocked(); idx >= 0 {
|
||||||
|
return idx
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sliceLetter maps a slice index to its SmartSDR letter (0→A, 1→B, …).
|
||||||
|
func sliceLetter(idx int) string {
|
||||||
|
if idx < 0 || idx > 25 {
|
||||||
|
return fmt.Sprintf("%d", idx)
|
||||||
|
}
|
||||||
|
return string(rune('A' + idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil.
|
||||||
|
// Caller holds f.mu.
|
||||||
|
func (f *Flex) txSliceLocked() *flexSlice {
|
||||||
|
for _, s := range f.slices {
|
||||||
|
if s.inUse && s.tx {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// operatingLocked resolves the operator's slices: the MAIN (active) slice for the
|
||||||
|
// mode/display, and — ONLY for a GENUINE split — the RX and TX slices. Split is
|
||||||
|
// the tx-flagged slice PLUS a distinct in-use slice on the SAME band (different
|
||||||
|
// freq) — detected from the pair itself, NOT from which slice is active (the TX
|
||||||
|
// slice often steals focus right after "slice create", which must NOT read as
|
||||||
|
// "no split"). A slice on another band is an independent receiver, ignored.
|
||||||
|
// Caller holds f.mu.
|
||||||
|
func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
|
||||||
|
_, main = f.mainSliceLocked()
|
||||||
|
txS := f.txSliceLocked()
|
||||||
|
if txS == nil {
|
||||||
|
return main, nil, nil
|
||||||
|
}
|
||||||
|
bt := BandFromHz(txS.freqHz)
|
||||||
|
if bt == "" {
|
||||||
|
return main, nil, nil
|
||||||
|
}
|
||||||
|
// RX = the active slice when it's a distinct same-band slice, else the first
|
||||||
|
// other in-use same-band slice.
|
||||||
|
if main != nil && main != txS && main.freqHz != txS.freqHz && BandFromHz(main.freqHz) == bt {
|
||||||
|
rx = main
|
||||||
|
} else {
|
||||||
|
for _, s := range f.slices {
|
||||||
|
if s.inUse && s != txS && s.freqHz != txS.freqHz && BandFromHz(s.freqHz) == bt {
|
||||||
|
rx = s
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rx == nil {
|
||||||
|
return main, nil, nil // tx slice alone (simplex) → not split
|
||||||
|
}
|
||||||
|
return main, rx, txS
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActiveSlice focuses slice idx on the radio so every subsequent command
|
||||||
|
// (freq / mode / DSP / spot click) targets it. Lets the operator pick the
|
||||||
|
// operating slice from OpsLog (like SliceLogger's A/B selector).
|
||||||
|
func (f *Flex) SetActiveSlice(idx int) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
_, exists := f.slices[idx]
|
||||||
|
connected := f.conn != nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("flex: no slice %d", idx)
|
||||||
|
}
|
||||||
|
f.send(fmt.Sprintf("slice s %d active=1", idx))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTXSlice makes slice idx the transmitter (tx=1) — e.g. "put TX on the active
|
||||||
|
// slice" so you transmit where you're listening. Only one slice can be TX; the
|
||||||
|
// radio clears the flag on the others.
|
||||||
|
func (f *Flex) SetTXSlice(idx int) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
_, exists := f.slices[idx]
|
||||||
|
connected := f.conn != nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("flex: no slice %d", idx)
|
||||||
|
}
|
||||||
|
f.send(fmt.Sprintf("slice s %d tx=1", idx))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Flex) SetFrequency(hz int64) error {
|
func (f *Flex) SetFrequency(hz int64) error {
|
||||||
if hz <= 0 {
|
if hz <= 0 {
|
||||||
return fmt.Errorf("flex: invalid frequency")
|
return fmt.Errorf("flex: invalid frequency")
|
||||||
@@ -1017,19 +1100,10 @@ func clampLevel(v int) int {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// rxSliceLocked returns the active RX slice and its index (-1 when none), using
|
// rxSliceLocked returns the operator's (main/active) slice and its index — the
|
||||||
// the same RX-selection rule as ReadState. Caller holds f.mu.
|
// slice every RX-DSP control and read targets. Caller holds f.mu.
|
||||||
func (f *Flex) rxSliceLocked() (int, *flexSlice) {
|
func (f *Flex) rxSliceLocked() (int, *flexSlice) {
|
||||||
rx, _ := f.pickSlicesLocked()
|
return f.mainSliceLocked()
|
||||||
if rx == nil {
|
|
||||||
return -1, nil
|
|
||||||
}
|
|
||||||
for i, s := range f.slices {
|
|
||||||
if s == rx {
|
|
||||||
return i, rx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1, rx
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FlexState returns a snapshot of the radio's transmit/ATU state plus the active
|
// FlexState returns a snapshot of the radio's transmit/ATU state plus the active
|
||||||
@@ -1062,10 +1136,31 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
CWSidetone: f.tx.cwSidetone,
|
CWSidetone: f.tx.cwSidetone,
|
||||||
CWMonLevel: f.tx.cwMonLevel,
|
CWMonLevel: f.tx.cwMonLevel,
|
||||||
}
|
}
|
||||||
if rx, tx := f.pickSlicesLocked(); rx != nil && tx != nil && rx != tx && rx.freqHz != tx.freqHz {
|
if _, rxS, txSplit := f.operatingLocked(); rxS != nil && txSplit != nil {
|
||||||
st.Split = true
|
st.Split = true
|
||||||
st.RXFreqHz = rx.freqHz
|
st.RXFreqHz = rxS.freqHz
|
||||||
st.TXFreqHz = tx.freqHz
|
st.TXFreqHz = txSplit.freqHz
|
||||||
|
}
|
||||||
|
// Every in-use slice (A/B/C/D…) so the panel shows them all and highlights the
|
||||||
|
// active/TX one — the active slice drives everything the operator does.
|
||||||
|
sidx := make([]int, 0, len(f.slices))
|
||||||
|
for i, s := range f.slices {
|
||||||
|
if s.inUse {
|
||||||
|
sidx = append(sidx, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Ints(sidx)
|
||||||
|
for _, i := range sidx {
|
||||||
|
s := f.slices[i]
|
||||||
|
st.Slices = append(st.Slices, FlexSliceInfo{
|
||||||
|
Index: i,
|
||||||
|
Letter: sliceLetter(i),
|
||||||
|
FreqHz: s.freqHz,
|
||||||
|
Mode: flexModeToADIF(s.mode),
|
||||||
|
Band: BandFromHz(s.freqHz),
|
||||||
|
Active: s.active,
|
||||||
|
TX: s.tx,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if _, rx := f.rxSliceLocked(); rx != nil {
|
if _, rx := f.rxSliceLocked(); rx != nil {
|
||||||
st.RXAvail = true
|
st.RXAvail = true
|
||||||
@@ -1198,35 +1293,40 @@ func (f *Flex) SetSplit(on bool) error {
|
|||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
return fmt.Errorf("flex: not connected")
|
return fmt.Errorf("flex: not connected")
|
||||||
}
|
}
|
||||||
rx, tx := f.pickSlicesLocked()
|
// Split is built AROUND THE ACTIVE slice, and "already split" uses the SAME
|
||||||
|
// same-band-pair rule as the button state (operatingLocked) — otherwise two
|
||||||
|
// INDEPENDENT slices on different bands look "already split" and SPLIT ON does
|
||||||
|
// nothing (the bug the user hit: A on 20m + B on 80m).
|
||||||
|
main, rxS, txS := f.operatingLocked()
|
||||||
rxIdx, txIdx := -1, -1
|
rxIdx, txIdx := -1, -1
|
||||||
for i, s := range f.slices {
|
for i, s := range f.slices {
|
||||||
if s == rx {
|
if s == rxS {
|
||||||
rxIdx = i
|
rxIdx = i
|
||||||
}
|
}
|
||||||
if s == tx {
|
if s == txS {
|
||||||
txIdx = i
|
txIdx = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
alreadySplit := rx != nil && tx != nil && rx != tx
|
alreadySplit := rxS != nil && txS != nil
|
||||||
var rxFreq int64
|
var baseFreq int64
|
||||||
var rxMode, rxAnt string
|
var baseMode, baseAnt string
|
||||||
if rx != nil {
|
if main != nil {
|
||||||
rxFreq, rxMode, rxAnt = rx.freqHz, rx.mode, rx.rxAnt
|
baseFreq, baseMode, baseAnt = main.freqHz, main.mode, main.rxAnt
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
|
||||||
if on {
|
if on {
|
||||||
if alreadySplit || rx == nil {
|
if alreadySplit || main == nil {
|
||||||
return nil // already split (or no slice)
|
return nil // already split, or no active slice
|
||||||
}
|
}
|
||||||
offset := int64(5000)
|
offset := int64(5000)
|
||||||
if strings.HasPrefix(strings.ToUpper(rxMode), "CW") {
|
if strings.HasPrefix(strings.ToUpper(baseMode), "CW") {
|
||||||
offset = 1000
|
offset = 1000
|
||||||
}
|
}
|
||||||
cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(rxFreq+offset)/1e6, rxMode)
|
// Create the TX slice at the ACTIVE slice's freq + offset (same band).
|
||||||
if rxAnt != "" {
|
cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(baseFreq+offset)/1e6, baseMode)
|
||||||
cmd += " ant=" + rxAnt
|
if baseAnt != "" {
|
||||||
|
cmd += " ant=" + baseAnt
|
||||||
}
|
}
|
||||||
if seq := f.send(cmd); seq > 0 {
|
if seq := f.send(cmd); seq > 0 {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
@@ -1239,10 +1339,10 @@ func (f *Flex) SetSplit(on bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if txIdx >= 0 {
|
if txIdx >= 0 {
|
||||||
f.send(fmt.Sprintf("slice remove %d", txIdx))
|
f.send(fmt.Sprintf("slice remove %d", txIdx)) // drop the extra TX slice
|
||||||
}
|
}
|
||||||
if rxIdx >= 0 {
|
if rxIdx >= 0 {
|
||||||
f.send(fmt.Sprintf("slice s %d tx=1", rxIdx))
|
f.send(fmt.Sprintf("slice s %d tx=1", rxIdx)) // RX slice transmits again
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,851 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
// icomnet.go — the NETWORK transport for the Icom backend. It talks the Icom IP
|
||||||
|
// remote protocol (the LAN server built into the IC-7610, the one the Icom
|
||||||
|
// Remote Utility speaks) directly, and presents the tunnelled CI-V byte stream
|
||||||
|
// as a plain civTransport (Read/Write). So the entire IcomController surface —
|
||||||
|
// freq/mode, receive-DSP, TX, scope, RIT, CW — runs unchanged over the network;
|
||||||
|
// only the transport differs. OpsLog thus replaces BOTH the Remote Utility and
|
||||||
|
// RS-BA1.
|
||||||
|
//
|
||||||
|
// The protocol (framing, passcode table, packet offsets, power-on) was
|
||||||
|
// reimplemented from the public wfview protocol and verified byte-for-byte
|
||||||
|
// against real Remote-Utility captures. No GPLv3 code is copied.
|
||||||
|
//
|
||||||
|
// Three UDP streams exist on the rig (control 50001 / CI-V 50002 / audio 50003);
|
||||||
|
// this transport uses control + CI-V (audio is not needed for CAT). Connect:
|
||||||
|
// control: areYouThere→iAmHere→areYouReady→iAmReady → login → token → conninfo
|
||||||
|
// civ: areYouThere→…→iAmReady → openClose(open) → power-on
|
||||||
|
// then CI-V flows in data packets. A pump goroutine keeps both streams alive
|
||||||
|
// (ping replies + idle keepalives) and feeds received CI-V bytes to Read.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var icnLE = binary.LittleEndian
|
||||||
|
var icnBE = binary.BigEndian
|
||||||
|
|
||||||
|
// NewIcomNet builds an (unconnected) Icom backend whose transport is the network
|
||||||
|
// stream. host is the rig's IP/hostname; user/pass are the rig's Network User1
|
||||||
|
// credentials. Reuses the whole IcomSerial controller — only `open` differs.
|
||||||
|
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *IcomSerial {
|
||||||
|
if civAddr <= 0 || civAddr > 0xFF {
|
||||||
|
civAddr = 0x98 // IC-7610
|
||||||
|
}
|
||||||
|
if digitalDefault == "" {
|
||||||
|
digitalDefault = "FT8"
|
||||||
|
}
|
||||||
|
b := &IcomSerial{
|
||||||
|
portName: host,
|
||||||
|
rigAddr: byte(civAddr),
|
||||||
|
digital: strings.ToUpper(digitalDefault),
|
||||||
|
model: "Icom",
|
||||||
|
scopeFixed: true,
|
||||||
|
}
|
||||||
|
b.open = func() (civTransport, error) {
|
||||||
|
if strings.TrimSpace(host) == "" {
|
||||||
|
return nil, fmt.Errorf("no rig host configured")
|
||||||
|
}
|
||||||
|
b.dialMu.Lock()
|
||||||
|
cancel := b.dialCancel
|
||||||
|
b.dialMu.Unlock()
|
||||||
|
return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// errDialCanceled is returned by dialIcomNet when Interrupt() aborts the dial
|
||||||
|
// (Stop/Start). The Manager treats it like any connect error and simply stops.
|
||||||
|
var errDialCanceled = fmt.Errorf("dial canceled")
|
||||||
|
|
||||||
|
// icnCanceled reports whether the dial has been asked to abort.
|
||||||
|
func icnCanceled(cancel <-chan struct{}) bool {
|
||||||
|
if cancel == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-cancel:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// icomNet is the connected network transport. It satisfies civTransport.
|
||||||
|
type icomNet struct {
|
||||||
|
ctrl *net.UDPConn // control stream (50001)
|
||||||
|
civ *net.UDPConn // CI-V stream (50002)
|
||||||
|
|
||||||
|
cID, cRemote uint32 // control stream ids
|
||||||
|
vID, vRemote uint32 // civ stream ids
|
||||||
|
|
||||||
|
// Tracked-packet sequence + CI-V data sequence. Written only by Write (on the
|
||||||
|
// CAT goroutine) and during dial — never by the pump — so no lock is needed.
|
||||||
|
vTracked uint16
|
||||||
|
vCivSeq uint16
|
||||||
|
|
||||||
|
rx chan []byte // CI-V byte chunks from civPump → Read (control replies)
|
||||||
|
scopeRx chan []byte // scope (0x27) frames, kept off rx so the panadapter
|
||||||
|
// stream can't crowd control replies out (→ ScopeChan)
|
||||||
|
leftover []byte // partial chunk not yet returned by Read (Read-only)
|
||||||
|
readTO time.Duration // Read timeout (SetReadTimeout)
|
||||||
|
|
||||||
|
// sentBuf keeps recently-sent tracked civ packets (by outer seq) so we can
|
||||||
|
// answer the rig's UDP retransmit requests. Written by Write (CAT goroutine),
|
||||||
|
// read by the pumps → guarded by sentMu.
|
||||||
|
sentMu sync.Mutex
|
||||||
|
sentBuf map[uint16][]byte
|
||||||
|
|
||||||
|
// Control-stream auth state, carried out of dial so ctrlPump can RENEW the
|
||||||
|
// login token every ~45 s. The rig invalidates the session ~2 min after login
|
||||||
|
// without renewal (this was the "loses control after 2 min" drop — RS-BA1/the
|
||||||
|
// Remote Utility renew too). Owned solely by ctrlPump after dial → no lock.
|
||||||
|
cTracked uint16 // control-stream tracked seq (continues after dial)
|
||||||
|
cAuthSeq uint16 // token-packet innerseq
|
||||||
|
cToken uint32 // login token (opaque, echoed back verbatim)
|
||||||
|
cTokReq uint16 // token-request id (echoed)
|
||||||
|
cSentBuf map[uint16][]byte // control-stream retransmit buffer (token renewals)
|
||||||
|
|
||||||
|
// Receive-side retransmit (CI-V stream): track the rig's data-packet send seq
|
||||||
|
// and ask it to resend any gap. Under the scope stream, UDP drops are common;
|
||||||
|
// without recovering them the gaps accumulate and the rig drops the WHOLE
|
||||||
|
// session after ~20 s (RS-BA1/wfview request retransmits, which is why they
|
||||||
|
// stay up with the panadapter on). Owned solely by civPump → no lock.
|
||||||
|
rxHaveSeq bool
|
||||||
|
rxLastSeq uint16
|
||||||
|
rxMissing map[uint16]int
|
||||||
|
|
||||||
|
// lastRx is the UnixNano of the last packet received from the rig (any type),
|
||||||
|
// updated by both pumps. The rig's network server answers pings/idles even
|
||||||
|
// when the RADIO is in standby, so this tracks the CONTROL-LINK liveness
|
||||||
|
// independently of whether CI-V is replying — letting ReadState tell "rig off
|
||||||
|
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
||||||
|
lastRx atomic.Int64
|
||||||
|
|
||||||
|
done chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScopeChan exposes the raw scope (0x27) CI-V frames for the scope feeder.
|
||||||
|
// Satisfies scopeTransport in icomserial.go.
|
||||||
|
func (n *icomNet) ScopeChan() <-chan []byte { return n.scopeRx }
|
||||||
|
|
||||||
|
// icnEnqueueDrop pushes onto a bounded channel, discarding the oldest entry when
|
||||||
|
// full — a lagging consumer never blocks the producer (used for the scope stream,
|
||||||
|
// where only the latest sweep matters).
|
||||||
|
func icnEnqueueDrop(ch chan []byte, v []byte) {
|
||||||
|
select {
|
||||||
|
case ch <- v:
|
||||||
|
default:
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ch <- v:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *icomNet) SetReadTimeout(d time.Duration) error { n.readTO = d; return nil }
|
||||||
|
func (n *icomNet) SetDTR(bool) error { return nil } // n/a on the network
|
||||||
|
func (n *icomNet) SetRTS(bool) error { return nil }
|
||||||
|
|
||||||
|
// markRx records that a packet just arrived from the rig (control-link liveness).
|
||||||
|
func (n *icomNet) markRx() { n.lastRx.Store(time.Now().UnixNano()) }
|
||||||
|
|
||||||
|
// Alive reports whether the rig's network server is still talking to us. The rig
|
||||||
|
// pings/idles continuously (even in standby), so a gap means the link — not just
|
||||||
|
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
|
||||||
|
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
|
||||||
|
func (n *icomNet) Alive() bool {
|
||||||
|
last := n.lastRx.Load()
|
||||||
|
if last == 0 {
|
||||||
|
return true // just connected, nothing received yet — give it a chance
|
||||||
|
}
|
||||||
|
return time.Since(time.Unix(0, last)) < 6*time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read returns tunnelled CI-V bytes, mimicking a serial port: (0,nil) on
|
||||||
|
// timeout, (n,nil) with data, (0,err) when the link is closed.
|
||||||
|
func (n *icomNet) Read(p []byte) (int, error) {
|
||||||
|
if len(n.leftover) > 0 {
|
||||||
|
k := copy(p, n.leftover)
|
||||||
|
n.leftover = n.leftover[k:]
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
to := n.readTO
|
||||||
|
if to <= 0 {
|
||||||
|
to = 60 * time.Millisecond
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case f, ok := <-n.rx:
|
||||||
|
if !ok {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
k := copy(p, f)
|
||||||
|
if k < len(f) {
|
||||||
|
n.leftover = append(n.leftover[:0], f[k:]...)
|
||||||
|
}
|
||||||
|
return k, nil
|
||||||
|
case <-time.After(to):
|
||||||
|
return 0, nil // timeout, no data
|
||||||
|
case <-n.done:
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write wraps raw CI-V bytes (FE FE … FD) in a data packet and sends them.
|
||||||
|
func (n *icomNet) Write(p []byte) (int, error) {
|
||||||
|
if icnTrace {
|
||||||
|
debugLog.Printf("icom net TX: % X", p)
|
||||||
|
}
|
||||||
|
seq := n.vTracked
|
||||||
|
pkt := icnCivData(seq, n.vID, n.vRemote, n.vCivSeq, p)
|
||||||
|
n.vTracked++
|
||||||
|
n.vCivSeq++
|
||||||
|
n.sentMu.Lock()
|
||||||
|
n.sentBuf[seq] = pkt
|
||||||
|
delete(n.sentBuf, seq-1024) // keep the buffer bounded (~last 1024 packets) so
|
||||||
|
// the rig's retransmit requests still hit even under sustained CW + poll load
|
||||||
|
n.sentMu.Unlock()
|
||||||
|
if _, err := n.civ.Write(pkt); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// icnTrace toggles verbose per-frame CI-V request/reply logging for diagnosing
|
||||||
|
// the network transport. Off by default (the connect-step logs stay); flip to
|
||||||
|
// true to trace every TX/RX again.
|
||||||
|
var icnTrace = false
|
||||||
|
|
||||||
|
func (n *icomNet) Close() error {
|
||||||
|
n.closeOnce.Do(func() {
|
||||||
|
close(n.done)
|
||||||
|
// Tell the rig we're leaving so it frees its SINGLE control session at
|
||||||
|
// once. If it never gets a disconnect it holds the session for minutes and
|
||||||
|
// refuses every new login — which is why a lost link (or a hard app exit)
|
||||||
|
// left the rig un-reconnectable, even from the Icom Remote Utility. UDP is
|
||||||
|
// lossy, so send openClose(close) + disconnect on both streams a few times.
|
||||||
|
// The whole teardown is bounded to ~90 ms so it never stalls the caller
|
||||||
|
// (Settings "Save & Close" / a reconnect's Disconnect).
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
_, _ = n.civ.Write(icnOpenClose(n.vTracked, n.vID, n.vRemote, n.vCivSeq, 0x00)) // close CI-V
|
||||||
|
_, _ = n.civ.Write(icnCtrl(0x05, 0, n.vID, n.vRemote)) // disconnect civ
|
||||||
|
_, _ = n.ctrl.Write(icnCtrl(0x05, 0, n.cID, n.cRemote)) // disconnect ctrl
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
debugLog.Printf("icom net: sent disconnect to rig (session released)")
|
||||||
|
_ = n.civ.Close()
|
||||||
|
_ = n.ctrl.Close()
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ctrlPump keeps the control stream (50001) alive: replies to the rig's pings,
|
||||||
|
// sends idle keepalives, RENEWS the login token every ~45 s (without this the rig
|
||||||
|
// invalidates the session after ~2 min → total loss of control), and answers the
|
||||||
|
// rig's retransmit requests for those tracked control packets. Its own goroutine
|
||||||
|
// so it never throttles civPump.
|
||||||
|
func (n *icomNet) ctrlPump() {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
lastIdle := time.Now()
|
||||||
|
lastToken := time.Now() // token was just granted during dial
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-n.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
_ = n.ctrl.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
|
||||||
|
if k, err := n.ctrl.Read(buf); err == nil && k >= 16 {
|
||||||
|
n.markRx()
|
||||||
|
switch icnLE.Uint16(buf[4:]) {
|
||||||
|
case 0x07: // ping
|
||||||
|
_, _ = n.ctrl.Write(icnPingReply(buf[:k], n.cID, n.cRemote))
|
||||||
|
case 0x01: // retransmit request — resend from the CONTROL sent-buffer
|
||||||
|
if k >= 8 {
|
||||||
|
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
||||||
|
}
|
||||||
|
case 0x05: // rig-initiated disconnect — it dropped US
|
||||||
|
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if time.Since(lastIdle) > 100*time.Millisecond {
|
||||||
|
_, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote))
|
||||||
|
lastIdle = time.Now()
|
||||||
|
}
|
||||||
|
if time.Since(lastToken) > 45*time.Second {
|
||||||
|
n.renewToken()
|
||||||
|
lastToken = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renewToken re-authorizes the session (control 0x40 token packet, requesttype
|
||||||
|
// 0x05). Tracked so a lost renewal can be retransmitted. Runs only on ctrlPump,
|
||||||
|
// the sole owner of the control-stream auth state, so no locking is needed.
|
||||||
|
func (n *icomNet) renewToken() {
|
||||||
|
seq := n.cTracked
|
||||||
|
pkt := icnTokenRenew(seq, n.cAuthSeq, n.cTokReq, n.cID, n.cRemote, n.cToken)
|
||||||
|
n.cTracked++
|
||||||
|
n.cAuthSeq++
|
||||||
|
n.cSentBuf[seq] = pkt
|
||||||
|
delete(n.cSentBuf, seq-256)
|
||||||
|
_, _ = n.ctrl.Write(pkt)
|
||||||
|
debugLog.Printf("icom net: token renewed (seq %d)", seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ctrlResend answers a control-stream retransmit request from the control
|
||||||
|
// sent-buffer (token renewals). Separate from resend(), which owns the CI-V
|
||||||
|
// buffer — the two streams have independent sequence spaces.
|
||||||
|
func (n *icomNet) ctrlResend(seq uint16) {
|
||||||
|
if pkt := n.cSentBuf[seq]; pkt != nil {
|
||||||
|
_, _ = n.ctrl.Write(pkt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// civPump owns the CI-V stream (50002): drains it as fast as packets arrive
|
||||||
|
// (its own goroutine — not throttled by the control reads), replies to pings,
|
||||||
|
// answers retransmit requests, skips scope frames, and feeds control CI-V bytes
|
||||||
|
// to Read via n.rx.
|
||||||
|
func (n *icomNet) civPump() {
|
||||||
|
buf := make([]byte, 8192)
|
||||||
|
lastIdle := time.Now()
|
||||||
|
lastReq := time.Now()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-n.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
_ = n.civ.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
|
||||||
|
if k, err := n.civ.Read(buf); err == nil && k >= 16 {
|
||||||
|
n.markRx()
|
||||||
|
switch typ := icnLE.Uint16(buf[4:]); {
|
||||||
|
case typ == 0x07: // ping
|
||||||
|
_, _ = n.civ.Write(icnPingReply(buf[:k], n.vID, n.vRemote))
|
||||||
|
case typ == 0x01: // retransmit request — resend that seq
|
||||||
|
if k >= 8 {
|
||||||
|
n.resend(icnLE.Uint16(buf[6:]))
|
||||||
|
}
|
||||||
|
case typ == 0x05: // rig-initiated disconnect — it dropped US
|
||||||
|
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
|
||||||
|
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
|
||||||
|
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
|
||||||
|
civBytes := buf[0x15:k]
|
||||||
|
cp := append([]byte(nil), civBytes...)
|
||||||
|
// Scope (0x27) frames go to their OWN channel: the panadapter streams
|
||||||
|
// continuously as large frames and would otherwise crowd control
|
||||||
|
// replies out of rx (every command would then time out). The scope
|
||||||
|
// feeder in IcomSerial picks them up. Everything else is a control
|
||||||
|
// reply → rx → Read.
|
||||||
|
if len(civBytes) >= 5 && civBytes[4] == 0x27 {
|
||||||
|
icnEnqueueDrop(n.scopeRx, cp)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if icnTrace {
|
||||||
|
debugLog.Printf("icom net RX: % X", civBytes)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case n.rx <- cp:
|
||||||
|
case <-n.done:
|
||||||
|
return
|
||||||
|
default: // buffer full — drop oldest, enqueue newest
|
||||||
|
select {
|
||||||
|
case <-n.rx:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case n.rx <- cp:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if time.Since(lastIdle) > 150*time.Millisecond {
|
||||||
|
_, _ = n.civ.Write(icnCtrl(0x00, 0, n.vID, n.vRemote))
|
||||||
|
lastIdle = time.Now()
|
||||||
|
}
|
||||||
|
if time.Since(lastReq) > 100*time.Millisecond {
|
||||||
|
n.sendRetransmitReq()
|
||||||
|
lastReq = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// icnMaxMissing caps the outstanding retransmit backlog; a bigger jump is treated
|
||||||
|
// as a wrap/desync and the tracker resets rather than requesting a storm.
|
||||||
|
const icnMaxMissing = 50
|
||||||
|
|
||||||
|
// trackRxSeq records the rig's data-packet send seq (outer seq @0x06) and flags
|
||||||
|
// any forward gap as missing so sendRetransmitReq can ask for it. Handles uint16
|
||||||
|
// wrap via the signed distance; ignores duplicates and already-seen packets.
|
||||||
|
func (n *icomNet) trackRxSeq(seq uint16) {
|
||||||
|
if !n.rxHaveSeq {
|
||||||
|
n.rxHaveSeq = true
|
||||||
|
n.rxLastSeq = seq
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch d := int16(seq - n.rxLastSeq); {
|
||||||
|
case d == 0: // duplicate
|
||||||
|
case d < 0: // an older seq arrived — a retransmit we were missing
|
||||||
|
delete(n.rxMissing, seq)
|
||||||
|
case d == 1: // in order
|
||||||
|
n.rxLastSeq = seq
|
||||||
|
case int(d) <= icnMaxMissing: // forward gap — mark the in-between seqs missing
|
||||||
|
for f := n.rxLastSeq + 1; f != seq; f++ {
|
||||||
|
n.rxMissing[f] = 0
|
||||||
|
}
|
||||||
|
n.rxLastSeq = seq
|
||||||
|
default: // huge jump (wrap/desync) — reset to avoid a false retransmit storm
|
||||||
|
n.rxMissing = make(map[uint16]int)
|
||||||
|
n.rxLastSeq = seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendRetransmitReq asks the rig to resend any CI-V data packets we detected as
|
||||||
|
// missing. Each seq is requested up to 4 times then dropped. Mirrors the Remote
|
||||||
|
// Utility/wfview format: a single miss = a 16-byte control (type 0x01, seq set);
|
||||||
|
// several = a control header + a list of [lo hi lo hi] per seq.
|
||||||
|
func (n *icomNet) sendRetransmitReq() {
|
||||||
|
if len(n.rxMissing) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(n.rxMissing) > icnMaxMissing {
|
||||||
|
n.rxMissing = make(map[uint16]int) // hopelessly behind — flush and move on
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var seqs []uint16
|
||||||
|
for s, cnt := range n.rxMissing {
|
||||||
|
if cnt >= 4 {
|
||||||
|
delete(n.rxMissing, s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n.rxMissing[s] = cnt + 1
|
||||||
|
seqs = append(seqs, s)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(seqs) == 0:
|
||||||
|
return
|
||||||
|
case len(seqs) == 1:
|
||||||
|
_, _ = n.civ.Write(icnCtrl(0x01, seqs[0], n.vID, n.vRemote))
|
||||||
|
default:
|
||||||
|
b := make([]byte, 16+4*len(seqs))
|
||||||
|
icnLE.PutUint32(b[0:], uint32(len(b)))
|
||||||
|
icnLE.PutUint16(b[4:], 0x01) // type = retransmit request
|
||||||
|
icnLE.PutUint32(b[8:], n.vID)
|
||||||
|
icnLE.PutUint32(b[12:], n.vRemote)
|
||||||
|
off := 16
|
||||||
|
for _, s := range seqs {
|
||||||
|
icnLE.PutUint16(b[off:], s)
|
||||||
|
icnLE.PutUint16(b[off+2:], s)
|
||||||
|
off += 4
|
||||||
|
}
|
||||||
|
_, _ = n.civ.Write(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resend re-transmits a previously-sent tracked CI-V packet the rig asks for
|
||||||
|
// (its UDP retransmit mechanism). Without this the rig drops the whole session
|
||||||
|
// after a few seconds when a packet is lost under load.
|
||||||
|
func (n *icomNet) resend(seq uint16) {
|
||||||
|
n.sentMu.Lock()
|
||||||
|
pkt := n.sentBuf[seq]
|
||||||
|
n.sentMu.Unlock()
|
||||||
|
if pkt != nil {
|
||||||
|
_, _ = n.civ.Write(pkt)
|
||||||
|
} else {
|
||||||
|
// The rig asked for a packet we've already evicted (>256 sent since). It
|
||||||
|
// can't fill its gap → it eventually drops the session. If this shows up in
|
||||||
|
// the log around a disconnect, the send buffer is too small for the load.
|
||||||
|
debugLog.Printf("icom net: retransmit MISS for seq %d (already evicted)", seq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------- connect -------------------------
|
||||||
|
|
||||||
|
func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}) (*icomNet, error) {
|
||||||
|
debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X)", host, user, compName, rigAddr)
|
||||||
|
// ---- control stream (50001): handshake → login → token → conninfo ----
|
||||||
|
craddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50001"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctrl, err := net.DialUDP("udp4", nil, craddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dial control: %w", err)
|
||||||
|
}
|
||||||
|
cID := icnLocalID(ctrl)
|
||||||
|
cRemote, err := icnHandshake(ctrl, cID, cancel)
|
||||||
|
if err != nil {
|
||||||
|
_ = ctrl.Close()
|
||||||
|
debugLog.Printf("icom net: control handshake FAILED (rig unreachable at %s:50001?): %v", host, err)
|
||||||
|
return nil, fmt.Errorf("control handshake: %w", err)
|
||||||
|
}
|
||||||
|
debugLog.Printf("icom net: control link up (rig id 0x%08X) — logging in", cRemote)
|
||||||
|
|
||||||
|
var cTracked, cInner uint16 = 1, 1
|
||||||
|
tokReq := uint16(0x0c77)
|
||||||
|
_, _ = ctrl.Write(icnLogin(cTracked, cInner, tokReq, cID, cRemote, 0, user, pass, compName))
|
||||||
|
cTracked++
|
||||||
|
cInner++
|
||||||
|
|
||||||
|
var token uint32
|
||||||
|
buf := make([]byte, 2048)
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for token == 0 && time.Now().Before(deadline) {
|
||||||
|
if icnCanceled(cancel) {
|
||||||
|
_ = ctrl.Close()
|
||||||
|
return nil, errDialCanceled
|
||||||
|
}
|
||||||
|
p, ok := icnRecv(ctrl, 200, buf)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
length := icnLE.Uint32(p[0:])
|
||||||
|
typ := icnLE.Uint16(p[4:])
|
||||||
|
if typ == 0x00 && length == 0x60 && len(p) >= 0x34 { // login response
|
||||||
|
token = icnLE.Uint32(p[0x1c:])
|
||||||
|
if e := icnLE.Uint32(p[0x30:]); e != 0 || token == 0 {
|
||||||
|
_ = ctrl.Close()
|
||||||
|
debugLog.Printf("icom net: LOGIN REJECTED (err=0x%08X) — wrong Network User1 ID/Password", e)
|
||||||
|
return nil, fmt.Errorf("login rejected — check the rig's Network User1 ID/Password")
|
||||||
|
}
|
||||||
|
_, _ = ctrl.Write(icnToken(cTracked, cInner, tokReq, cID, cRemote, token))
|
||||||
|
cTracked++
|
||||||
|
cInner++
|
||||||
|
debugLog.Printf("icom net: LOGIN OK, token 0x%08X", token)
|
||||||
|
} else if typ == 0x07 {
|
||||||
|
_, _ = ctrl.Write(icnPingReply(p, cID, cRemote))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if token == 0 {
|
||||||
|
_ = ctrl.Close()
|
||||||
|
debugLog.Printf("icom net: login TIMED OUT (no token in 5s) — check host/credentials")
|
||||||
|
return nil, fmt.Errorf("login timed out (no token) — check host/credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Learn the rig's MAC from its conninfo push (144B) to echo in our conninfo.
|
||||||
|
var rigMAC []byte
|
||||||
|
macEnd := time.Now().Add(1200 * time.Millisecond)
|
||||||
|
for time.Now().Before(macEnd) {
|
||||||
|
if icnCanceled(cancel) {
|
||||||
|
_ = ctrl.Close()
|
||||||
|
return nil, errDialCanceled
|
||||||
|
}
|
||||||
|
p, ok := icnRecv(ctrl, 150, buf)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(p) >= 0x30 && icnLE.Uint32(p[0:]) == 0x90 { // 144-byte conninfo push
|
||||||
|
rigMAC = append([]byte(nil), p[0x2a:0x30]...)
|
||||||
|
}
|
||||||
|
if icnLE.Uint16(p[4:]) == 0x07 {
|
||||||
|
_, _ = ctrl.Write(icnPingReply(p, cID, cRemote))
|
||||||
|
}
|
||||||
|
if rigMAC != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rigMAC == nil {
|
||||||
|
rigMAC = make([]byte, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003))
|
||||||
|
cTracked++
|
||||||
|
cInner++
|
||||||
|
drainEnd := time.Now().Add(500 * time.Millisecond)
|
||||||
|
for time.Now().Before(drainEnd) {
|
||||||
|
if icnCanceled(cancel) {
|
||||||
|
_ = ctrl.Close()
|
||||||
|
return nil, errDialCanceled
|
||||||
|
}
|
||||||
|
if p, ok := icnRecv(ctrl, 100, buf); ok && icnLE.Uint16(p[4:]) == 0x07 {
|
||||||
|
_, _ = ctrl.Write(icnPingReply(p, cID, cRemote))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CI-V stream (50002): bind LOCAL :50002 (the announced civport) ----
|
||||||
|
vraddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50002"))
|
||||||
|
if err != nil {
|
||||||
|
_ = ctrl.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
debugLog.Printf("icom net: conninfo sent (rig mac % X) — opening CI-V stream", rigMAC)
|
||||||
|
civ, err := net.DialUDP("udp4", &net.UDPAddr{Port: 50002}, vraddr)
|
||||||
|
if err != nil {
|
||||||
|
_ = ctrl.Close()
|
||||||
|
debugLog.Printf("icom net: cannot bind local :50002 — the Icom Remote Utility is probably still running: %v", err)
|
||||||
|
return nil, fmt.Errorf("dial CI-V (local :50002 — is the Icom Remote Utility still running?): %w", err)
|
||||||
|
}
|
||||||
|
vID := icnLocalID(civ)
|
||||||
|
vRemote, err := icnHandshake(civ, vID, cancel)
|
||||||
|
if err != nil {
|
||||||
|
_ = civ.Close()
|
||||||
|
_ = ctrl.Close()
|
||||||
|
debugLog.Printf("icom net: CI-V handshake FAILED: %v", err)
|
||||||
|
return nil, fmt.Errorf("CI-V handshake: %w", err)
|
||||||
|
}
|
||||||
|
debugLog.Printf("icom net: CI-V link up — opening the CI-V data flow (rig power left to the ON button)")
|
||||||
|
|
||||||
|
// Bigger receive buffers so a burst of scope/CI-V packets doesn't overflow
|
||||||
|
// (dropped packets → the rig's retransmit requests → session drop).
|
||||||
|
_ = ctrl.SetReadBuffer(1 << 20)
|
||||||
|
_ = civ.SetReadBuffer(1 << 20)
|
||||||
|
|
||||||
|
n := &icomNet{
|
||||||
|
ctrl: ctrl, civ: civ,
|
||||||
|
cID: cID, cRemote: cRemote, vID: vID, vRemote: vRemote,
|
||||||
|
vTracked: 1, vCivSeq: 1,
|
||||||
|
rx: make(chan []byte, 256),
|
||||||
|
scopeRx: make(chan []byte, 8),
|
||||||
|
sentBuf: make(map[uint16][]byte),
|
||||||
|
rxMissing: make(map[uint16]int),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
// Auth state for periodic token renewal (see ctrlPump). cTracked/cAuthSeq
|
||||||
|
// continue the control-stream sequences from where the dial's login/token/
|
||||||
|
// conninfo left off.
|
||||||
|
cTracked: cTracked, cAuthSeq: cInner,
|
||||||
|
cToken: token, cTokReq: tokReq,
|
||||||
|
cSentBuf: make(map[uint16][]byte),
|
||||||
|
}
|
||||||
|
n.markRx() // the successful handshake counts as initial rig activity
|
||||||
|
// openClose(open) starts the CI-V data flow. We intentionally DO NOT power the
|
||||||
|
// rig on here — that's a manual ON button now (the user asked not to wake the
|
||||||
|
// rig at launch). If the rig is in standby the control/CI-V streams still stay
|
||||||
|
// up and Alive() stays true (the rig's server answers pings even when the radio
|
||||||
|
// is off), so the session doesn't flap; CI-V just stays silent until ON.
|
||||||
|
ocPkt := icnOpenClose(n.vTracked, vID, vRemote, n.vCivSeq, 0x04)
|
||||||
|
n.sentBuf[n.vTracked] = ocPkt
|
||||||
|
_, _ = civ.Write(ocPkt)
|
||||||
|
n.vTracked++
|
||||||
|
n.vCivSeq++
|
||||||
|
|
||||||
|
go n.ctrlPump()
|
||||||
|
go n.civPump()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// icnHandshake: areYouThere(seq0) → iAmHere → areYouReady(seq1) → iAmReady.
|
||||||
|
func icnHandshake(c *net.UDPConn, myID uint32, cancel <-chan struct{}) (uint32, error) {
|
||||||
|
buf := make([]byte, 2048)
|
||||||
|
_, _ = c.Write(icnCtrl(0x03, 0, myID, 0))
|
||||||
|
var remoteID uint32
|
||||||
|
deadline := time.Now().Add(4 * time.Second)
|
||||||
|
lastTry := time.Now()
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if icnCanceled(cancel) {
|
||||||
|
return 0, errDialCanceled
|
||||||
|
}
|
||||||
|
p, ok := icnRecv(c, 200, buf)
|
||||||
|
if !ok {
|
||||||
|
if remoteID == 0 && time.Since(lastTry) > 500*time.Millisecond {
|
||||||
|
_, _ = c.Write(icnCtrl(0x03, 0, myID, 0))
|
||||||
|
lastTry = time.Now()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
typ := icnLE.Uint16(p[4:])
|
||||||
|
sentid := icnLE.Uint32(p[8:])
|
||||||
|
switch typ {
|
||||||
|
case 0x04: // iAmHere
|
||||||
|
remoteID = sentid
|
||||||
|
_, _ = c.Write(icnCtrl(0x06, 1, myID, remoteID))
|
||||||
|
case 0x06: // iAmReady
|
||||||
|
if remoteID != 0 {
|
||||||
|
return remoteID, nil
|
||||||
|
}
|
||||||
|
case 0x07: // ping
|
||||||
|
_, _ = c.Write(icnPingReply(p, myID, remoteID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("handshake timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
func icnRecv(c *net.UDPConn, ms int, buf []byte) ([]byte, bool) {
|
||||||
|
_ = c.SetReadDeadline(time.Now().Add(time.Duration(ms) * time.Millisecond))
|
||||||
|
k, err := c.Read(buf)
|
||||||
|
if err != nil || k < 16 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return buf[:k], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func icnLocalID(c *net.UDPConn) uint32 {
|
||||||
|
a := c.LocalAddr().(*net.UDPAddr)
|
||||||
|
ip := a.IP.To4()
|
||||||
|
if ip == nil {
|
||||||
|
ip = []byte{192, 168, 0, 1}
|
||||||
|
}
|
||||||
|
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(uint16(a.Port))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------- packet builders -------------------------
|
||||||
|
// (offsets verified vs wfview structs + real captures)
|
||||||
|
|
||||||
|
func icnCtrl(typ, seq uint16, sentid, rcvdid uint32) []byte {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
icnLE.PutUint32(b[0:], 0x10)
|
||||||
|
icnLE.PutUint16(b[4:], typ)
|
||||||
|
icnLE.PutUint16(b[6:], seq)
|
||||||
|
icnLE.PutUint32(b[8:], sentid)
|
||||||
|
icnLE.PutUint32(b[12:], rcvdid)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func icnPingReply(pkt []byte, myID, remoteID uint32) []byte {
|
||||||
|
r := append([]byte(nil), pkt...)
|
||||||
|
if len(r) >= 17 {
|
||||||
|
icnLE.PutUint32(r[8:], myID)
|
||||||
|
icnLE.PutUint32(r[12:], remoteID)
|
||||||
|
r[16] = 0x01
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func icnLogin(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user, pass, name string) []byte {
|
||||||
|
b := make([]byte, 0x80)
|
||||||
|
icnLE.PutUint32(b[0:], 0x80)
|
||||||
|
icnLE.PutUint16(b[6:], seq)
|
||||||
|
icnLE.PutUint32(b[8:], sentid)
|
||||||
|
icnLE.PutUint32(b[12:], rcvdid)
|
||||||
|
icnBE.PutUint32(b[0x10:], 0x80-0x10)
|
||||||
|
b[0x14] = 0x01
|
||||||
|
b[0x15] = 0x00
|
||||||
|
icnBE.PutUint16(b[0x16:], innerSeq)
|
||||||
|
icnLE.PutUint16(b[0x1a:], tokReq)
|
||||||
|
icnLE.PutUint32(b[0x1c:], token)
|
||||||
|
copy(b[0x40:0x50], icnPasscode(user))
|
||||||
|
copy(b[0x50:0x60], icnPasscode(pass))
|
||||||
|
nm := name
|
||||||
|
if len(nm) > 16 {
|
||||||
|
nm = nm[:16]
|
||||||
|
}
|
||||||
|
copy(b[0x60:0x70], []byte(nm))
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func icnToken(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) []byte {
|
||||||
|
b := make([]byte, 0x40)
|
||||||
|
icnLE.PutUint32(b[0:], 0x40)
|
||||||
|
icnLE.PutUint16(b[6:], seq)
|
||||||
|
icnLE.PutUint32(b[8:], sentid)
|
||||||
|
icnLE.PutUint32(b[12:], rcvdid)
|
||||||
|
icnBE.PutUint32(b[0x10:], 0x40-0x10)
|
||||||
|
b[0x14] = 0x01
|
||||||
|
b[0x15] = 0x02
|
||||||
|
icnBE.PutUint16(b[0x16:], innerSeq)
|
||||||
|
icnLE.PutUint16(b[0x1a:], tokReq)
|
||||||
|
icnLE.PutUint32(b[0x1c:], token)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// icnTokenRenew builds the periodic token-renewal packet (control 0x40). Same as
|
||||||
|
// the login-time token confirm but requesttype 0x05 (renew) with the resetcap
|
||||||
|
// field (0x0798 BE @0x24) the Remote Utility sends on renewals. Keeps the rig
|
||||||
|
// from invalidating the session (~2-min timeout without renewal). Offsets per the
|
||||||
|
// wfview token_packet struct (verified) — protocol facts, not copied code.
|
||||||
|
func icnTokenRenew(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) []byte {
|
||||||
|
b := make([]byte, 0x40)
|
||||||
|
icnLE.PutUint32(b[0:], 0x40)
|
||||||
|
icnLE.PutUint16(b[6:], seq)
|
||||||
|
icnLE.PutUint32(b[8:], sentid)
|
||||||
|
icnLE.PutUint32(b[12:], rcvdid)
|
||||||
|
icnBE.PutUint32(b[0x10:], 0x40-0x10)
|
||||||
|
b[0x14] = 0x01 // requestreply = request
|
||||||
|
b[0x15] = 0x05 // requesttype = token renewal
|
||||||
|
icnBE.PutUint16(b[0x16:], innerSeq)
|
||||||
|
icnLE.PutUint16(b[0x1a:], tokReq)
|
||||||
|
icnLE.PutUint32(b[0x1c:], token)
|
||||||
|
icnBE.PutUint16(b[0x24:], 0x0798) // resetcap
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16) []byte {
|
||||||
|
b := make([]byte, 0x90)
|
||||||
|
icnLE.PutUint32(b[0:], 0x90)
|
||||||
|
icnLE.PutUint16(b[6:], seq)
|
||||||
|
icnLE.PutUint32(b[8:], sentid)
|
||||||
|
icnLE.PutUint32(b[12:], rcvdid)
|
||||||
|
icnBE.PutUint32(b[0x10:], 0x90-0x10)
|
||||||
|
b[0x14] = 0x01
|
||||||
|
b[0x15] = 0x03 // requesttype = conninfo / open streams
|
||||||
|
icnBE.PutUint16(b[0x16:], innerSeq)
|
||||||
|
icnLE.PutUint16(b[0x1a:], tokReq)
|
||||||
|
icnLE.PutUint32(b[0x1c:], token)
|
||||||
|
icnLE.PutUint16(b[0x27:], 0x8010) // commoncap
|
||||||
|
copy(b[0x2a:0x30], rigMAC)
|
||||||
|
copy(b[0x40:0x60], []byte("IC-7610"))
|
||||||
|
copy(b[0x60:0x70], icnPasscode(user))
|
||||||
|
b[0x70] = 0x00 // rxenable (audio off — CI-V only)
|
||||||
|
b[0x71] = 0x00 // txenable
|
||||||
|
b[0x72] = 0x10 // rxcodec
|
||||||
|
b[0x73] = 0x04 // txcodec
|
||||||
|
icnBE.PutUint32(b[0x74:], 16000)
|
||||||
|
icnBE.PutUint32(b[0x78:], 8000)
|
||||||
|
icnBE.PutUint32(b[0x7c:], uint32(civPort))
|
||||||
|
icnBE.PutUint32(b[0x80:], uint32(audioPort))
|
||||||
|
icnBE.PutUint32(b[0x84:], 100)
|
||||||
|
b[0x88] = 0x00
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func icnOpenClose(seq uint16, sentid, rcvdid uint32, civSeq uint16, magic byte) []byte {
|
||||||
|
b := make([]byte, 0x16)
|
||||||
|
icnLE.PutUint32(b[0:], 0x16)
|
||||||
|
icnLE.PutUint16(b[6:], seq)
|
||||||
|
icnLE.PutUint32(b[8:], sentid)
|
||||||
|
icnLE.PutUint32(b[12:], rcvdid)
|
||||||
|
icnLE.PutUint16(b[0x10:], 0x01c0)
|
||||||
|
icnBE.PutUint16(b[0x13:], civSeq)
|
||||||
|
b[0x15] = magic
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func icnCivData(seq uint16, sentid, rcvdid uint32, civSeq uint16, civ []byte) []byte {
|
||||||
|
nn := 0x15 + len(civ)
|
||||||
|
b := make([]byte, nn)
|
||||||
|
icnLE.PutUint32(b[0:], uint32(nn))
|
||||||
|
icnLE.PutUint16(b[6:], seq)
|
||||||
|
icnLE.PutUint32(b[8:], sentid)
|
||||||
|
icnLE.PutUint32(b[12:], rcvdid)
|
||||||
|
b[0x10] = 0xc1
|
||||||
|
icnLE.PutUint16(b[0x11:], uint16(len(civ)))
|
||||||
|
icnBE.PutUint16(b[0x13:], civSeq)
|
||||||
|
copy(b[0x15:], civ)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// icnPasscodeSeq — Icom's obfuscation table (values at index 0x20..0x7e).
|
||||||
|
// VERIFIED: user "f6bgc" → 3F 65 50 25 55 (matches the capture).
|
||||||
|
var icnPasscodeSeq = [256]byte{
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0x47, 0x5d, 0x4c, 0x42, 0x66, 0x20, 0x23, 0x46, 0x4e, 0x57, 0x45, 0x3d, 0x67, 0x76, 0x60, 0x41, 0x62, 0x39, 0x59, 0x2d, 0x68, 0x7e,
|
||||||
|
0x7c, 0x65, 0x7d, 0x49, 0x29, 0x72, 0x73, 0x78, 0x21, 0x6e, 0x5a, 0x5e, 0x4a, 0x3e, 0x71, 0x2c, 0x2a, 0x54, 0x3c, 0x3a, 0x63, 0x4f,
|
||||||
|
0x43, 0x75, 0x27, 0x79, 0x5b, 0x35, 0x70, 0x48, 0x6b, 0x56, 0x6f, 0x34, 0x32, 0x6c, 0x30, 0x61, 0x6d, 0x7b, 0x2f, 0x4b, 0x64, 0x38,
|
||||||
|
0x2b, 0x2e, 0x50, 0x40, 0x3f, 0x55, 0x33, 0x37, 0x25, 0x77, 0x24, 0x26, 0x74, 0x6a, 0x28, 0x53, 0x4d, 0x69, 0x22, 0x5c, 0x44, 0x31,
|
||||||
|
0x36, 0x58, 0x3b, 0x7a, 0x51, 0x5f, 0x52,
|
||||||
|
}
|
||||||
|
|
||||||
|
func icnPasscode(s string) []byte {
|
||||||
|
out := make([]byte, 0, len(s))
|
||||||
|
for i := 0; i < len(s) && i < 16; i++ {
|
||||||
|
p := int(s[i]) + i
|
||||||
|
if p > 126 {
|
||||||
|
p = 32 + p%127
|
||||||
|
}
|
||||||
|
out = append(out, icnPasscodeSeq[p])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+667
-20
@@ -12,18 +12,58 @@ import (
|
|||||||
"go.bug.st/serial"
|
"go.bug.st/serial"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IcomSerial controls an Icom transceiver over its USB/serial CI-V port (local
|
// civTransport is everything IcomSerial needs from its underlying link. It's
|
||||||
// control). It speaks the shared civ protocol, so when the network backend
|
// satisfied directly by a go.bug.st serial.Port (USB, local control) and, for
|
||||||
// (icomnet) is added it will reuse the same encode/decode — only the transport
|
// remote control, by the icomnet UDP stream which presents the tunnelled CI-V
|
||||||
// changes. Implements Backend; all methods run on the Manager's CAT goroutine,
|
// byte stream as plain Read/Write (there SetDTR/SetRTS/SetReadTimeout are
|
||||||
// so the port is accessed single-threaded (no locking needed).
|
// no-ops). Abstracting it here means the whole IcomController surface — DSP,
|
||||||
|
// scope, RIT, CW — is reused unchanged over the network; only `open` differs.
|
||||||
|
type civTransport interface {
|
||||||
|
Read(p []byte) (int, error)
|
||||||
|
Write(p []byte) (int, error)
|
||||||
|
Close() error
|
||||||
|
SetReadTimeout(time.Duration) error
|
||||||
|
SetDTR(bool) error
|
||||||
|
SetRTS(bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// aliveTransport is an OPTIONAL transport capability: report whether the link is
|
||||||
|
// still up independently of whether the rig answers CI-V. The network transport
|
||||||
|
// implements it (the rig's server pings even in standby), letting ReadState keep
|
||||||
|
// the session "connected but rig off" instead of tearing it down and flapping.
|
||||||
|
// USB doesn't implement it (no such out-of-band signal), so it keeps the bounded
|
||||||
|
// read-failure tolerance instead.
|
||||||
|
type aliveTransport interface {
|
||||||
|
Alive() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeTransport is an OPTIONAL transport capability: deliver spectrum-scope
|
||||||
|
// (0x27) frames on a SEPARATE channel from control replies. The network transport
|
||||||
|
// implements it so the continuous panadapter stream can't crowd control replies
|
||||||
|
// out of the main Read path (which made every command time out with the scope
|
||||||
|
// on). USB doesn't implement it — there the scope frames ride the normal Read
|
||||||
|
// path and the reader splits them off to specCh.
|
||||||
|
type scopeTransport interface {
|
||||||
|
ScopeChan() <-chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomSerial controls an Icom transceiver over the shared civ protocol. The
|
||||||
|
// transport is pluggable via `open`: NewIcomSerial opens a USB/serial port;
|
||||||
|
// NewIcomNet (later) returns one configured with a network transport. Implements
|
||||||
|
// Backend; all methods run on the Manager's CAT goroutine, so the port is
|
||||||
|
// accessed single-threaded (no locking needed).
|
||||||
type IcomSerial struct {
|
type IcomSerial struct {
|
||||||
portName string
|
portName string
|
||||||
baud int
|
baud int
|
||||||
rigAddr byte // rig's CI-V address (IC-7610 default 0x98)
|
rigAddr byte // rig's CI-V address (IC-7610 default 0x98)
|
||||||
digital string // mode to command for DATA (FT8/RTTY/…)
|
digital string // mode to command for DATA (FT8/RTTY/…)
|
||||||
|
|
||||||
port serial.Port
|
// open dials the underlying link (serial port or network stream). Set by the
|
||||||
|
// constructor; called by Connect. Blocks until connected (the network opener
|
||||||
|
// performs the full UDP handshake + login before returning).
|
||||||
|
open func() (civTransport, error)
|
||||||
|
|
||||||
|
port civTransport
|
||||||
model string
|
model string
|
||||||
|
|
||||||
// I/O routing. A single reader goroutine owns port.Read and dispatches every
|
// I/O routing. A single reader goroutine owns port.Read and dispatches every
|
||||||
@@ -55,6 +95,8 @@ type IcomSerial struct {
|
|||||||
splitOn bool // last read split state (refreshed every few cycles)
|
splitOn bool // last read split state (refreshed every few cycles)
|
||||||
splitTXFreq int64 // last read unselected/TX VFO freq while in split
|
splitTXFreq int64 // last read unselected/TX VFO freq while in split
|
||||||
readFails int // consecutive ReadState freq-read failures (transient tolerance)
|
readFails int // consecutive ReadState freq-read failures (transient tolerance)
|
||||||
|
dspLoaded bool // readDSP has run since the rig became responsive (loads all
|
||||||
|
// the panel's set-once controls once the rig actually answers)
|
||||||
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
|
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
|
||||||
lastSetFreqAt time.Time
|
lastSetFreqAt time.Time
|
||||||
|
|
||||||
@@ -63,6 +105,29 @@ type IcomSerial struct {
|
|||||||
// / setters) — hence the mutex.
|
// / setters) — hence the mutex.
|
||||||
dspMu sync.Mutex
|
dspMu sync.Mutex
|
||||||
dsp IcomTXState
|
dsp IcomTXState
|
||||||
|
|
||||||
|
// dialCancel is closed by Interrupt() to abort an in-progress network dial
|
||||||
|
// (icomnet's handshake/login/boot-wait can block ~tens of seconds). A fresh
|
||||||
|
// channel is made by each Connect. Guarded by dialMu: written on the CAT
|
||||||
|
// goroutine, closed from the goroutine calling Stop.
|
||||||
|
dialMu sync.Mutex
|
||||||
|
dialCancel chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interrupt aborts an in-progress network Connect so Stop()/Start() don't block
|
||||||
|
// on a slow UDP handshake (or the 25 s boot-from-standby wait). Safe to call at
|
||||||
|
// any time and from another goroutine; harmless when no dial is in progress and
|
||||||
|
// a no-op for the USB transport (which dials instantly).
|
||||||
|
func (b *IcomSerial) Interrupt() {
|
||||||
|
b.dialMu.Lock()
|
||||||
|
if b.dialCancel != nil {
|
||||||
|
select {
|
||||||
|
case <-b.dialCancel: // already closed
|
||||||
|
default:
|
||||||
|
close(b.dialCancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.dialMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -82,7 +147,7 @@ func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *I
|
|||||||
if digitalDefault == "" {
|
if digitalDefault == "" {
|
||||||
digitalDefault = "FT8"
|
digitalDefault = "FT8"
|
||||||
}
|
}
|
||||||
return &IcomSerial{
|
b := &IcomSerial{
|
||||||
portName: portName,
|
portName: portName,
|
||||||
baud: baud,
|
baud: baud,
|
||||||
rigAddr: byte(civAddr),
|
rigAddr: byte(civAddr),
|
||||||
@@ -90,20 +155,37 @@ func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *I
|
|||||||
model: "Icom",
|
model: "Icom",
|
||||||
scopeFixed: true, // rigs default to a fixed-span scope
|
scopeFixed: true, // rigs default to a fixed-span scope
|
||||||
}
|
}
|
||||||
|
// USB/serial transport opener.
|
||||||
|
b.open = func() (civTransport, error) {
|
||||||
|
if b.portName == "" {
|
||||||
|
return nil, fmt.Errorf("no serial port configured")
|
||||||
|
}
|
||||||
|
p, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open %s @ %d baud: %w", b.portName, b.baud, err)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *IcomSerial) Name() string { return "icom" }
|
func (b *IcomSerial) Name() string { return "icom" }
|
||||||
|
|
||||||
func (b *IcomSerial) Connect() error {
|
func (b *IcomSerial) Connect() error {
|
||||||
if b.portName == "" {
|
if b.open == nil {
|
||||||
return fmt.Errorf("no serial port configured")
|
return fmt.Errorf("no transport configured")
|
||||||
}
|
}
|
||||||
port, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud})
|
// Fresh cancel channel for this dial so Interrupt() (called by Stop) can abort
|
||||||
|
// a slow network handshake instead of freezing the UI.
|
||||||
|
b.dialMu.Lock()
|
||||||
|
b.dialCancel = make(chan struct{})
|
||||||
|
b.dialMu.Unlock()
|
||||||
|
port, err := b.open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("open %s @ %d baud: %w", b.portName, b.baud, err)
|
return err
|
||||||
}
|
}
|
||||||
// Short read timeout so recv() polls in a tight loop without blocking the
|
// Short read timeout so recv() polls in a tight loop without blocking the
|
||||||
// CAT goroutine when the rig is silent.
|
// CAT goroutine when the rig is silent. (No-op on the network transport.)
|
||||||
_ = port.SetReadTimeout(60 * time.Millisecond)
|
_ = port.SetReadTimeout(60 * time.Millisecond)
|
||||||
// Deassert DTR/RTS. Icom USB rigs (IC-7610, IC-7300…) let "USB SEND" and
|
// Deassert DTR/RTS. Icom USB rigs (IC-7610, IC-7300…) let "USB SEND" and
|
||||||
// "USB Keying (CW)" be mapped to the RTS or DTR line: if the port opens with
|
// "USB Keying (CW)" be mapped to the RTS or DTR line: if the port opens with
|
||||||
@@ -122,6 +204,11 @@ func (b *IcomSerial) Connect() error {
|
|||||||
b.readerDone = make(chan struct{})
|
b.readerDone = make(chan struct{})
|
||||||
go b.reader(port, b.readerDone)
|
go b.reader(port, b.readerDone)
|
||||||
go b.scopeLoop(b.specCh, b.readerDone)
|
go b.scopeLoop(b.specCh, b.readerDone)
|
||||||
|
// On the network the scope frames come on their own channel (kept off the
|
||||||
|
// control Read path); feed them into the same scope pipeline.
|
||||||
|
if sc, ok := port.(scopeTransport); ok {
|
||||||
|
go b.netScopeFeeder(sc.ScopeChan(), b.readerDone)
|
||||||
|
}
|
||||||
|
|
||||||
// Best-effort model identification: ask the rig for its own CI-V address.
|
// Best-effort model identification: ask the rig for its own CI-V address.
|
||||||
if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil {
|
if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil {
|
||||||
@@ -134,7 +221,11 @@ func (b *IcomSerial) Connect() error {
|
|||||||
// Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub
|
// Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub
|
||||||
// selector byte; single-scope rigs (IC-7300…) do not.
|
// selector byte; single-scope rigs (IC-7300…) do not.
|
||||||
b.dualScope = b.rigAddr == 0x98 || b.rigAddr == 0xA2
|
b.dualScope = b.rigAddr == 0x98 || b.rigAddr == 0xA2
|
||||||
b.readDSP() // best-effort initial snapshot for the control tab
|
// 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
|
||||||
|
// would time out and leave every control at 0 / off with no retry. ReadState
|
||||||
|
// loads it once on the first successful freq read instead (see dspLoaded).
|
||||||
|
b.dspLoaded = false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,11 +250,38 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
|||||||
|
|
||||||
hz, err := b.readFreq()
|
hz, err := b.readFreq()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The rig briefly stops answering CI-V while it switches band/VFO. Treat a
|
// Network transport: if the control link is still alive, the rig is simply
|
||||||
// few consecutive misses as transient — keep the connection and report the
|
// silent — either in standby / powered OFF (the ON button is manual now), or
|
||||||
// last known state — so a band change doesn't trigger a full disconnect +
|
// mid band-change. Stay CONNECTED and show last-known state (empty until the
|
||||||
// 5 s reconnect (which showed the new frequency ~10 s late). Only after
|
// rig is switched on) rather than tearing the whole UDP session down and
|
||||||
// several failures do we declare the rig lost so the Manager reconnects.
|
// flapping every few seconds. The panel stays up so the ON button works.
|
||||||
|
if at, ok := b.port.(aliveTransport); ok {
|
||||||
|
if at.Alive() {
|
||||||
|
b.readFails = 0
|
||||||
|
s.FreqHz = b.curFreq // 0 until the rig is powered on and first read
|
||||||
|
if b.curModeByte != 0 {
|
||||||
|
s.Mode = civ.ModeToADIF(b.curModeByte, false)
|
||||||
|
if s.Mode == "DATA" {
|
||||||
|
s.Mode = b.digital
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep the Icom panel visible (so ON/OFF are reachable) but show no
|
||||||
|
// live meters while the rig is silent.
|
||||||
|
b.dspMu.Lock()
|
||||||
|
b.dsp.Available = true
|
||||||
|
b.dsp.Model = b.model
|
||||||
|
b.dsp.Transmitting = false
|
||||||
|
b.dsp.SMeter, b.dsp.PowerMeter, b.dsp.SWRMeter = 0, 0, 0
|
||||||
|
b.dspMu.Unlock()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
return RigState{}, err // control link dead → let the Manager reconnect
|
||||||
|
}
|
||||||
|
// USB (no liveness signal): the rig briefly stops answering CI-V while it
|
||||||
|
// switches band/VFO. Tolerate a few consecutive misses as transient — keep
|
||||||
|
// the connection and report last-known state — so a band change doesn't
|
||||||
|
// trigger a full disconnect + 5 s reconnect. Only after several failures do
|
||||||
|
// we declare the rig lost so the Manager reconnects.
|
||||||
b.readFails++
|
b.readFails++
|
||||||
if b.readFails <= 6 && b.curFreq > 0 {
|
if b.readFails <= 6 && b.curFreq > 0 {
|
||||||
s.FreqHz = b.curFreq
|
s.FreqHz = b.curFreq
|
||||||
@@ -236,6 +354,15 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
|||||||
b.dsp.PowerMeter = po
|
b.dsp.PowerMeter = po
|
||||||
b.dsp.SWRMeter = swr
|
b.dsp.SWRMeter = swr
|
||||||
b.dspMu.Unlock()
|
b.dspMu.Unlock()
|
||||||
|
|
||||||
|
// First time the rig answers (it's booted/responsive): load the full DSP
|
||||||
|
// snapshot so the panel's antenna, sliders, RIT, notch, etc. reflect the rig
|
||||||
|
// instead of sitting at their zero defaults. Runs once; ↻ Refresh re-reads on
|
||||||
|
// demand, and a reconnect re-arms it (Connect clears dspLoaded).
|
||||||
|
if !b.dspLoaded {
|
||||||
|
b.readDSP()
|
||||||
|
b.dspLoaded = true
|
||||||
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,9 +401,39 @@ func (b *IcomSerial) SetPTT(on bool) error {
|
|||||||
return b.exec(civ.CmdPTT, civ.SubPTT, state)
|
return b.exec(civ.CmdPTT, civ.SubPTT, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPower turns the transceiver on or off (CI-V 0x18). Power-ON is prefixed with
|
||||||
|
// a run of 0xFE — the wake preamble Icom rigs need to notice a command while
|
||||||
|
// asleep (harmless when already awake); after it the rig boots for ~10-15 s.
|
||||||
|
// Sent raw with no ack wait, since a rig waking up or shutting down won't
|
||||||
|
// reliably answer. On the network transport the whole buffer becomes one data
|
||||||
|
// packet, exactly as the Remote Utility sends it. Power is manual (the app never
|
||||||
|
// wakes the rig on connect), so this is driven by the panel's ON/OFF button.
|
||||||
|
func (b *IcomSerial) SetPower(on bool) error {
|
||||||
|
if b.port == nil {
|
||||||
|
return fmt.Errorf("icom: not connected")
|
||||||
|
}
|
||||||
|
if on {
|
||||||
|
buf := make([]byte, 0, 32)
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
buf = append(buf, 0xFE)
|
||||||
|
}
|
||||||
|
buf = append(buf, 0xFE, 0xFE, b.rigAddr, civ.AddrController, civ.CmdPower, 0x01, 0xFD)
|
||||||
|
_, err := b.port.Write(buf)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, civ.CmdPower, 0x00))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (b *IcomSerial) write(payload ...byte) error {
|
func (b *IcomSerial) write(payload ...byte) error {
|
||||||
|
// Not connected (rig off / port dropped): fail cleanly instead of
|
||||||
|
// dereferencing a nil port — a Set* dispatched while disconnected (e.g.
|
||||||
|
// clicking Scope ON with the radio off) would otherwise panic the app.
|
||||||
|
if b.port == nil {
|
||||||
|
return fmt.Errorf("icom: not connected")
|
||||||
|
}
|
||||||
// Drop any stale/unsolicited frames buffered from before this command so
|
// Drop any stale/unsolicited frames buffered from before this command so
|
||||||
// recv() only sees the reply to THIS request (avoids a previous command's ack
|
// recv() only sees the reply to THIS request (avoids a previous command's ack
|
||||||
// or an unsolicited dial-turn update being mistaken for our response).
|
// or an unsolicited dial-turn update being mistaken for our response).
|
||||||
@@ -287,8 +444,14 @@ func (b *IcomSerial) write(payload ...byte) error {
|
|||||||
|
|
||||||
// recv waits for a frame the reader routed to respCh that satisfies match, or
|
// recv waits for a frame the reader routed to respCh that satisfies match, or
|
||||||
// times out. The reader has already discarded echoes and split off scope frames,
|
// times out. The reader has already discarded echoes and split off scope frames,
|
||||||
// so recv only ever sees candidate control replies.
|
// so recv only ever sees candidate control replies. It also bails out at once if
|
||||||
|
// Interrupt() fires (Stop) so an in-flight ReadState — which can be a dozen reads,
|
||||||
|
// each up to icomReadTimeout when the rig is slow — doesn't make Stop/Save-&-Close
|
||||||
|
// wait several seconds for the poll goroutine to finish.
|
||||||
func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (civ.Decoded, error) {
|
func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (civ.Decoded, error) {
|
||||||
|
b.dialMu.Lock()
|
||||||
|
cancel := b.dialCancel
|
||||||
|
b.dialMu.Unlock()
|
||||||
deadline := time.After(timeout)
|
deadline := time.After(timeout)
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -296,6 +459,8 @@ func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (
|
|||||||
if match(f) {
|
if match(f) {
|
||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
case <-cancel:
|
||||||
|
return civ.Decoded{}, fmt.Errorf("icom: interrupted")
|
||||||
case <-deadline:
|
case <-deadline:
|
||||||
return civ.Decoded{}, fmt.Errorf("icom: timeout waiting for response")
|
return civ.Decoded{}, fmt.Errorf("icom: timeout waiting for response")
|
||||||
}
|
}
|
||||||
@@ -306,7 +471,7 @@ func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (
|
|||||||
// frames and routes each: our own echoes are dropped, spectrum-scope frames
|
// frames and routes each: our own echoes are dropped, spectrum-scope frames
|
||||||
// (0x27) go to specCh, everything else (control replies, acks, unsolicited
|
// (0x27) go to specCh, everything else (control replies, acks, unsolicited
|
||||||
// transceive updates) goes to respCh. It exits when the port is closed.
|
// transceive updates) goes to respCh. It exits when the port is closed.
|
||||||
func (b *IcomSerial) reader(port serial.Port, done chan struct{}) {
|
func (b *IcomSerial) reader(port civTransport, done chan struct{}) {
|
||||||
defer close(done)
|
defer close(done)
|
||||||
tmp := make([]byte, 512)
|
tmp := make([]byte, 512)
|
||||||
var rx []byte
|
var rx []byte
|
||||||
@@ -336,6 +501,37 @@ func (b *IcomSerial) reader(port serial.Port, done chan struct{}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// netScopeFeeder decodes the raw scope (0x27) CI-V frames the network transport
|
||||||
|
// delivers on its own channel and routes them into specCh — the same pipeline
|
||||||
|
// the USB reader feeds — so scopeLoop assembles them identically. Exits when the
|
||||||
|
// connection's reader does (done closes on Disconnect).
|
||||||
|
func (b *IcomSerial) netScopeFeeder(ch <-chan []byte, done chan struct{}) {
|
||||||
|
var buf []byte
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case raw, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf = append(buf, raw...)
|
||||||
|
frames, consumed := civ.Scan(buf)
|
||||||
|
if consumed > 0 {
|
||||||
|
buf = append(buf[:0], buf[consumed:]...)
|
||||||
|
}
|
||||||
|
for _, f := range frames {
|
||||||
|
if f.From == b.rigAddr && f.Cmd == civ.CmdScope {
|
||||||
|
b.route(b.specCh, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(buf) > 1<<16 { // a frame that never completes — don't grow forever
|
||||||
|
buf = buf[:0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// route delivers a frame without ever blocking the reader: if the channel is
|
// route delivers a frame without ever blocking the reader: if the channel is
|
||||||
// full it drops the oldest entry to make room for the newest.
|
// full it drops the oldest entry to make room for the newest.
|
||||||
func (b *IcomSerial) route(ch chan civ.Decoded, f civ.Decoded) {
|
func (b *IcomSerial) route(ch chan civ.Decoded, f civ.Decoded) {
|
||||||
@@ -413,6 +609,46 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
|||||||
if seq == 0 || tot == 0 {
|
if seq == 0 || tot == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if tot == 1 {
|
||||||
|
// Network single-frame sweep: the WHOLE sweep is in one frame —
|
||||||
|
// region = [info][low 5-BCD][high 5-BCD][amplitude bytes…]. Parse the
|
||||||
|
// edges and take the rest as the trace, then publish immediately.
|
||||||
|
// (USB splits this across 21 frames; the net rig sends it as one.)
|
||||||
|
if len(region) >= 11 {
|
||||||
|
// Net single-frame layout (IC-7610): region = [info 1B][freq1 5-BCD]
|
||||||
|
// [freq2 5-BCD][amplitude bytes]. The two freq fields depend on the
|
||||||
|
// scope mode: FIXED sends [low-edge][high-edge] (both absolute), CENTRE
|
||||||
|
// sends [centre][span]. Tell them apart by magnitude — a second value
|
||||||
|
// BIGGER than the first is a real high edge; a small one is a span
|
||||||
|
// (e.g. 14.200 MHz + 100 kHz → centred 14.150-14.250; 21.000 +
|
||||||
|
// 21.070 → fixed 21.000-21.070). Guessing wrong here gave the absurd
|
||||||
|
// 21.000-42.070 span (low + a 21 MHz "span").
|
||||||
|
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 edge set)
|
||||||
|
} else {
|
||||||
|
low, high = v1-v2/2, v1+v2/2 // centre + span (centre-on-VFO)
|
||||||
|
}
|
||||||
|
amp := append([]byte(nil), region[11:]...)
|
||||||
|
b.scopeMu.Lock()
|
||||||
|
b.scopeLow, b.scopeHigh = low, high
|
||||||
|
b.scopeAmp = amp
|
||||||
|
b.scopeSeq++
|
||||||
|
firstLog := !b.scopeSeen
|
||||||
|
b.scopeSeen = true
|
||||||
|
b.scopeMu.Unlock()
|
||||||
|
if firstLog {
|
||||||
|
head := region
|
||||||
|
if len(head) > 16 {
|
||||||
|
head = head[:16]
|
||||||
|
}
|
||||||
|
applog.Printf("icom scope (net 1-frame): head=[% X] v1=%d v2=%d → %d..%d Hz points=%d", head, v1, v2, low, high, len(amp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
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
|
||||||
@@ -514,6 +750,205 @@ func (b *IcomSerial) SetScopeMode(fixed bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// icScopeRanges maps a frequency to the IC-7610's spectrum edge-frequency range
|
||||||
|
// id (from Hamlib's ic7610 caps). SetScopeEdges needs it to address the right
|
||||||
|
// fixed-edge memory. Each range spans a chunk of the tuning range.
|
||||||
|
var icScopeRanges = []struct {
|
||||||
|
lo, hi int64
|
||||||
|
id byte
|
||||||
|
}{
|
||||||
|
{30_000, 1_600_000, 1}, {1_600_000, 2_000_000, 2}, {2_000_000, 6_000_000, 3},
|
||||||
|
{6_000_000, 8_000_000, 4}, {8_000_000, 11_000_000, 5}, {11_000_000, 15_000_000, 6},
|
||||||
|
{15_000_000, 20_000_000, 7}, {20_000_000, 22_000_000, 8}, {22_000_000, 26_000_000, 9},
|
||||||
|
{26_000_000, 30_000_000, 10}, {30_000_000, 45_000_000, 11}, {45_000_000, 60_000_000, 12},
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeRangeBCD returns the range id (as a 1-byte BCD) for a frequency, or 0 if
|
||||||
|
// out of range.
|
||||||
|
func scopeRangeBCD(freq int64) byte {
|
||||||
|
for _, r := range icScopeRanges {
|
||||||
|
if freq >= r.lo && freq < r.hi {
|
||||||
|
return byte(r.id/10)<<4 | byte(r.id%10) // 1-byte BCD (11 → 0x11)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetScopeEdges points the FIXED-mode scope at [low..high] by writing them into
|
||||||
|
// the rig's fixed-edge memory (edge set 1) and making that set active. This is
|
||||||
|
// how the panel's "centre on VFO" and pan ◀/▶ work: they just compute VFO±50 kHz
|
||||||
|
// (and shift it) and set the edges — no dependence on the waveform decode. CI-V:
|
||||||
|
// 0x27 0x14 fixed, 0x27 0x16 set 1 active, 0x27 0x1e [range][set][low][high].
|
||||||
|
func (b *IcomSerial) SetScopeEdges(low, high int64) error {
|
||||||
|
if low <= 0 || high <= low {
|
||||||
|
return fmt.Errorf("icom scope: bad edges %d..%d", low, high)
|
||||||
|
}
|
||||||
|
rangeID := scopeRangeBCD((low + high) / 2)
|
||||||
|
if rangeID == 0 {
|
||||||
|
return fmt.Errorf("icom scope: freq out of range")
|
||||||
|
}
|
||||||
|
if b.dualScope {
|
||||||
|
_ = b.exec(civ.CmdScope, civ.SubScopeMode, 0x00, 0x01) // fixed mode (main)
|
||||||
|
_ = b.exec(civ.CmdScope, civ.SubScopeEdge, 0x00, 0x01) // activate edge set 1
|
||||||
|
} else {
|
||||||
|
_ = b.exec(civ.CmdScope, civ.SubScopeMode, 0x01)
|
||||||
|
_ = b.exec(civ.CmdScope, civ.SubScopeEdge, 0x01)
|
||||||
|
}
|
||||||
|
payload := append([]byte{civ.CmdScope, civ.SubScopeFixEdge, rangeID, 0x01}, civ.FreqToBCD(low)...)
|
||||||
|
payload = append(payload, civ.FreqToBCD(high)...)
|
||||||
|
b.scopeMu.Lock()
|
||||||
|
b.scopeFixed = true
|
||||||
|
b.scopeMu.Unlock()
|
||||||
|
return b.exec(payload...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRIT sets the RIT/ΔTX offset (signed Hz, ±9999).
|
||||||
|
func (b *IcomSerial) SetRIT(hz int) error {
|
||||||
|
if err := b.exec(append([]byte{civ.CmdRIT, civ.SubRITFreq}, civ.RITToBCD(hz)...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if hz < -9999 {
|
||||||
|
hz = -9999
|
||||||
|
}
|
||||||
|
if hz > 9999 {
|
||||||
|
hz = 9999
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.RITHz = hz })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetRITOn(on bool) error {
|
||||||
|
if err := b.exec(civ.CmdRIT, civ.SubRITOn, boolByte(on)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.RITOn = on })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetXITOn(on bool) error {
|
||||||
|
if err := b.exec(civ.CmdRIT, civ.SubXITOn, boolByte(on)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.XITOn = on })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendCW keys a CW message through the rig's internal keyer (CI-V 0x17). The
|
||||||
|
// text is upper-cased and filtered to keyer-legal characters; the radio must be
|
||||||
|
// in CW mode. Messages longer than 30 characters are split across several 0x17
|
||||||
|
// commands (the rig queues them).
|
||||||
|
func (b *IcomSerial) SendCW(text string) error {
|
||||||
|
msg := civ.FilterCW(text)
|
||||||
|
if msg == "" {
|
||||||
|
applog.Printf("icom cw: nothing to send (filtered %q → empty)", text)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
applog.Printf("icom cw: send %q (%d chars) to rig 0x%02X", msg, len(msg), b.rigAddr)
|
||||||
|
for len(msg) > 0 {
|
||||||
|
n := len(msg)
|
||||||
|
if n > 30 {
|
||||||
|
n = 30
|
||||||
|
}
|
||||||
|
chunk := msg[:n]
|
||||||
|
msg = msg[n:]
|
||||||
|
payload := append([]byte{civ.CmdSendCW}, []byte(chunk)...)
|
||||||
|
if err := b.write(payload...); err != nil {
|
||||||
|
applog.Printf("icom cw: write failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A missing ack is NOT fatal: some firmwares don't acknowledge 0x17, and
|
||||||
|
// the message bytes were already written. Only an explicit NG (0xFA) means
|
||||||
|
// the rig refused it (typically: not in CW mode / break-in off).
|
||||||
|
f, err := b.recv(icomCmdTimeout, func(d civ.Decoded) bool { return d.Cmd == civ.OK || d.Cmd == civ.NG })
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("icom cw: chunk %q written, no ack (sent anyway): %v", chunk, err)
|
||||||
|
} else if f.Cmd == civ.NG {
|
||||||
|
applog.Printf("icom cw: rig REJECTED CW (0xFA) — put the rig in CW mode / enable break-in")
|
||||||
|
return fmt.Errorf("icom: rig rejected CW — check CW mode / break-in")
|
||||||
|
} else {
|
||||||
|
applog.Printf("icom cw: chunk %q acked OK", chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBreakIn sets CW break-in (0=OFF, 1=SEMI, 2=FULL). Break-in must be on for
|
||||||
|
// the 0x17 CW keyer to actually switch the rig to transmit.
|
||||||
|
func (b *IcomSerial) SetBreakIn(mode int) error {
|
||||||
|
if mode < 0 {
|
||||||
|
mode = 0
|
||||||
|
}
|
||||||
|
if mode > 2 {
|
||||||
|
mode = 2
|
||||||
|
}
|
||||||
|
applog.Printf("icom cw: set break-in %d", mode)
|
||||||
|
if err := b.exec(civ.CmdSwitch, civ.SubSwBreakIn, byte(mode)); err != nil {
|
||||||
|
applog.Printf("icom cw: set break-in failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.BreakIn = mode })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopCW aborts a CW message currently being sent (0x17 with the 0xFF stop code).
|
||||||
|
func (b *IcomSerial) StopCW() error {
|
||||||
|
applog.Printf("icom cw: stop")
|
||||||
|
if err := b.write(civ.CmdSendCW, civ.StopCWByte); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _ = b.recv(icomCmdTimeout, func(d civ.Decoded) bool { return d.Cmd == civ.OK || d.Cmd == civ.NG })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKeySpeed sets the CW keyer speed in WPM (CmdLevel 0x0C).
|
||||||
|
func (b *IcomSerial) SetKeySpeed(wpm int) error {
|
||||||
|
lvl := civ.WPMToKeyLevel(wpm)
|
||||||
|
applog.Printf("icom cw: set key speed %d WPM (level %d)", wpm, lvl)
|
||||||
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelKeySpeed}, civ.LevelToBCD(lvl)...)...); err != nil {
|
||||||
|
applog.Printf("icom cw: set key speed failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if wpm < civ.KeyMinWPM {
|
||||||
|
wpm = civ.KeyMinWPM
|
||||||
|
}
|
||||||
|
if wpm > civ.KeyMaxWPM {
|
||||||
|
wpm = civ.KeyMaxWPM
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.KeySpeedWPM = wpm })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRIT reads the offset + RIT/ΔTX on-off flags into st (best-effort).
|
||||||
|
func (b *IcomSerial) readRIT(st *IcomTXState) {
|
||||||
|
if err := b.write(civ.CmdRIT, civ.SubRITFreq); err == nil {
|
||||||
|
if f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.CmdRIT && len(d.Data) >= 4 && d.Data[0] == civ.SubRITFreq
|
||||||
|
}); err == nil {
|
||||||
|
st.RITHz = civ.BCDToRIT(f.Data[1:4])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := b.readSwitchSub(civ.CmdRIT, civ.SubRITOn); ok {
|
||||||
|
st.RITOn = v != 0
|
||||||
|
}
|
||||||
|
if v, ok := b.readSwitchSub(civ.CmdRIT, civ.SubXITOn); ok {
|
||||||
|
st.XITOn = v != 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSwitchSub reads a 1-byte on/off value for cmd+sub (generalises readSwitch).
|
||||||
|
func (b *IcomSerial) readSwitchSub(cmd, sub byte) (byte, bool) {
|
||||||
|
if err := b.write(cmd, sub); err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == cmd && len(d.Data) >= 2 && d.Data[0] == sub
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return f.Data[1], true
|
||||||
|
}
|
||||||
|
|
||||||
// ScopeData returns a copy of the latest reassembled sweep as a number array.
|
// ScopeData returns a copy of the latest reassembled sweep as a number array.
|
||||||
func (b *IcomSerial) ScopeData() ScopeSweep {
|
func (b *IcomSerial) ScopeData() ScopeSweep {
|
||||||
b.scopeMu.Lock()
|
b.scopeMu.Lock()
|
||||||
@@ -731,6 +1166,9 @@ func (b *IcomSerial) readDSP() {
|
|||||||
if v, ok := b.readSwitch(civ.SubSwANF); ok {
|
if v, ok := b.readSwitch(civ.SubSwANF); ok {
|
||||||
st.ANF = v != 0
|
st.ANF = v != 0
|
||||||
}
|
}
|
||||||
|
if v, ok := b.readSwitch(civ.SubSwAPF); ok {
|
||||||
|
st.APF = v != 0
|
||||||
|
}
|
||||||
if v, ok := b.readSwitch(civ.SubSwAGC); ok {
|
if v, ok := b.readSwitch(civ.SubSwAGC); ok {
|
||||||
st.AGC = agcName(v)
|
st.AGC = agcName(v)
|
||||||
}
|
}
|
||||||
@@ -743,6 +1181,53 @@ func (b *IcomSerial) readDSP() {
|
|||||||
if _, f, ok := b.readModeFilter(); ok {
|
if _, f, ok := b.readModeFilter(); ok {
|
||||||
st.Filter = int(f)
|
st.Filter = int(f)
|
||||||
}
|
}
|
||||||
|
b.readRIT(&st)
|
||||||
|
if v, ok := b.readLevel(civ.SubLevelKeySpeed); ok {
|
||||||
|
st.KeySpeedWPM = civ.KeyLevelToWPM(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readSwitch(civ.SubSwBreakIn); ok {
|
||||||
|
st.BreakIn = int(v)
|
||||||
|
}
|
||||||
|
// Antenna + filter fine controls + TX extras.
|
||||||
|
if v, ok := b.readAnt(); ok {
|
||||||
|
st.Antenna = v
|
||||||
|
}
|
||||||
|
if v, ok := b.readLevel(civ.SubLevelPBTIn); ok {
|
||||||
|
st.PBTInner = from255(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readLevel(civ.SubLevelPBTOut); ok {
|
||||||
|
st.PBTOuter = from255(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readSwitch(civ.SubSwMN); ok {
|
||||||
|
st.ManualNotch = v != 0
|
||||||
|
}
|
||||||
|
if v, ok := b.readLevel(civ.SubLevelNotch); ok {
|
||||||
|
st.NotchPos = from255(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readLevel(civ.SubLevelSQL); ok {
|
||||||
|
st.Squelch = from255(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readSwitch(civ.SubSwComp); ok {
|
||||||
|
st.Comp = v != 0
|
||||||
|
}
|
||||||
|
if v, ok := b.readLevel(civ.SubLevelComp); ok {
|
||||||
|
st.CompLevel = from255(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readSwitch(civ.SubSwMon); ok {
|
||||||
|
st.Monitor = v != 0
|
||||||
|
}
|
||||||
|
if v, ok := b.readLevel(civ.SubLevelMon); ok {
|
||||||
|
st.MonLevel = from255(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readSwitch(civ.SubSwVOX); ok {
|
||||||
|
st.VOX = v != 0
|
||||||
|
}
|
||||||
|
if v, ok := b.readLevel(civ.SubLevelVOXGain); ok {
|
||||||
|
st.VOXGain = from255(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readLevel(civ.SubLevelAntiVOX); ok {
|
||||||
|
st.AntiVOX = from255(v)
|
||||||
|
}
|
||||||
|
|
||||||
b.dspMu.Lock()
|
b.dspMu.Lock()
|
||||||
b.dsp = st
|
b.dsp = st
|
||||||
@@ -790,6 +1275,22 @@ func (b *IcomSerial) readAtt() (int, bool) {
|
|||||||
return civ.BCDToByte(f.Data[0]), true
|
return civ.BCDToByte(f.Data[0]), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) readAnt() (int, bool) {
|
||||||
|
if err := b.write(civ.CmdAnt); err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.CmdAnt && len(d.Data) >= 1
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if f.Data[0] == 0x01 {
|
||||||
|
return 2, true
|
||||||
|
}
|
||||||
|
return 1, true
|
||||||
|
}
|
||||||
|
|
||||||
func (b *IcomSerial) readModeFilter() (mode, filter byte, ok bool) {
|
func (b *IcomSerial) readModeFilter() (mode, filter byte, ok bool) {
|
||||||
if err := b.write(civ.CmdReadMode); err != nil {
|
if err := b.write(civ.CmdReadMode); err != nil {
|
||||||
return 0, 0, false
|
return 0, 0, false
|
||||||
@@ -859,6 +1360,14 @@ func (b *IcomSerial) SetANF(on bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetAPF(on bool) error {
|
||||||
|
if err := b.exec(civ.CmdSwitch, civ.SubSwAPF, boolByte(on)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.APF = on })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (b *IcomSerial) SetAGC(name string) error {
|
func (b *IcomSerial) SetAGC(name string) error {
|
||||||
v := agcValue(name)
|
v := agcValue(name)
|
||||||
if v == 0 {
|
if v == 0 {
|
||||||
@@ -924,6 +1433,24 @@ func (b *IcomSerial) SetMicGain(p int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *IcomSerial) SetIcomSplit(on bool) error {
|
func (b *IcomSerial) SetIcomSplit(on bool) error {
|
||||||
|
if on {
|
||||||
|
// Enable split with the usual "work him up" TX offset: +1 kHz on CW,
|
||||||
|
// +5 kHz otherwise (SSB). Set the unselected (TX) VFO to RX+offset first,
|
||||||
|
// then turn split on. 0x25 0x01 + BCD sets the unselected VFO's frequency.
|
||||||
|
rx := b.curFreq
|
||||||
|
if rx <= 0 {
|
||||||
|
if hz, err := b.readFreq(); err == nil {
|
||||||
|
rx = hz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rx > 0 {
|
||||||
|
offset := int64(5000)
|
||||||
|
if b.curModeByte == civ.ModeCW || b.curModeByte == civ.ModeCWR {
|
||||||
|
offset = 1000
|
||||||
|
}
|
||||||
|
_ = b.exec(append([]byte{civ.CmdVfoFreq, civ.SubVfoUnselected}, civ.FreqToBCD(rx+offset)...)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := b.exec(civ.CmdSplit, boolByte(on)); err != nil {
|
if err := b.exec(civ.CmdSplit, boolByte(on)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -931,6 +1458,126 @@ func (b *IcomSerial) SetIcomSplit(on bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Antenna ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetAntenna(n int) error {
|
||||||
|
sub := byte(0x00) // ANT1
|
||||||
|
if n == 2 {
|
||||||
|
sub = 0x01 // ANT2
|
||||||
|
}
|
||||||
|
if err := b.exec(civ.CmdAnt, sub); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) {
|
||||||
|
if n == 2 {
|
||||||
|
s.Antenna = 2
|
||||||
|
} else {
|
||||||
|
s.Antenna = 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter: Twin PBT + manual notch ────────────────────────────────────────
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetPBTInner(p int) error {
|
||||||
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelPBTIn}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.PBTInner = clampPct(p) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetPBTOuter(p int) error {
|
||||||
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelPBTOut}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.PBTOuter = clampPct(p) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetManualNotch(on bool) error {
|
||||||
|
if err := b.exec(civ.CmdSwitch, civ.SubSwMN, boolByte(on)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.ManualNotch = on })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetNotchPos(p int) error {
|
||||||
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNotch}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.NotchPos = clampPct(p) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TX extras: squelch / compressor / monitor / VOX ────────────────────────
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetSquelch(p int) error {
|
||||||
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelSQL}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.Squelch = clampPct(p) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetComp(on bool) error {
|
||||||
|
if err := b.exec(civ.CmdSwitch, civ.SubSwComp, boolByte(on)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.Comp = on })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetCompLevel(p int) error {
|
||||||
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelComp}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.CompLevel = clampPct(p) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetMonitor(on bool) error {
|
||||||
|
if err := b.exec(civ.CmdSwitch, civ.SubSwMon, boolByte(on)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.Monitor = on })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetMonLevel(p int) error {
|
||||||
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelMon}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.MonLevel = clampPct(p) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetVOX(on bool) error {
|
||||||
|
if err := b.exec(civ.CmdSwitch, civ.SubSwVOX, boolByte(on)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.VOX = on })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetVOXGain(p int) error {
|
||||||
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelVOXGain}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.VOXGain = clampPct(p) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetAntiVOX(p int) error {
|
||||||
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelAntiVOX}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.AntiVOX = clampPct(p) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// TuneATU triggers a one-shot antenna-tuner tune (CI-V 0x1C 0x01 0x02).
|
// TuneATU triggers a one-shot antenna-tuner tune (CI-V 0x1C 0x01 0x02).
|
||||||
func (b *IcomSerial) TuneATU() error {
|
func (b *IcomSerial) TuneATU() error {
|
||||||
return b.exec(civ.CmdATU, civ.SubATU, 0x02)
|
return b.exec(civ.CmdATU, civ.SubATU, 0x02)
|
||||||
|
|||||||
+29
-1
@@ -3,6 +3,7 @@
|
|||||||
package cat
|
package cat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -29,6 +30,7 @@ type TCI struct {
|
|||||||
|
|
||||||
mu sync.Mutex // guards conn + writes + state
|
mu sync.Mutex // guards conn + writes + state
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
|
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
|
||||||
ready bool
|
ready bool
|
||||||
|
|
||||||
// Cached state pushed by the radio.
|
// Cached state pushed by the radio.
|
||||||
@@ -70,8 +72,18 @@ func (t *TCI) Connect() error {
|
|||||||
return fmt.Errorf("tci: no host configured")
|
return fmt.Errorf("tci: no host configured")
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("ws://%s", net.JoinHostPort(host, strconv.Itoa(port)))
|
url := fmt.Sprintf("ws://%s", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||||
|
// Cancellable dial so Interrupt() (Stop / Settings "Save & Close") aborts it at
|
||||||
|
// once instead of waiting out a dead server's 5 s handshake timeout.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
t.mu.Lock()
|
||||||
|
t.dialCancel = cancel
|
||||||
|
t.mu.Unlock()
|
||||||
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
|
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
|
||||||
conn, _, err := dialer.Dial(url, nil)
|
conn, _, err := dialer.DialContext(ctx, url, nil)
|
||||||
|
cancel()
|
||||||
|
t.mu.Lock()
|
||||||
|
t.dialCancel = nil
|
||||||
|
t.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("tci: connect %s: %w", url, err)
|
return fmt.Errorf("tci: connect %s: %w", url, err)
|
||||||
}
|
}
|
||||||
@@ -122,6 +134,22 @@ func (t *TCI) Disconnect() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Interrupt aborts an in-flight Connect dial so Stop()/Start() don't block on a
|
||||||
|
// dead server's handshake timeout. Satisfies the Manager's interruptible
|
||||||
|
// interface. Safe from another goroutine; a no-op when not dialing.
|
||||||
|
func (t *TCI) Interrupt() {
|
||||||
|
t.mu.Lock()
|
||||||
|
cancel := t.dialCancel
|
||||||
|
c := t.conn
|
||||||
|
t.mu.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
if c != nil {
|
||||||
|
_ = c.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ReadState returns the cached state pushed by the radio.
|
// ReadState returns the cached state pushed by the radio.
|
||||||
func (t *TCI) ReadState() (RigState, error) {
|
func (t *TCI) ReadState() (RigState, error) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ var catalogue = []Def{
|
|||||||
{"WW-DIGI", "World Wide Digi DX Contest", ExGrid},
|
{"WW-DIGI", "World Wide Digi DX Contest", ExGrid},
|
||||||
{"EU-HF-C", "EU HF Championship", ExSerial},
|
{"EU-HF-C", "EU HF Championship", ExSerial},
|
||||||
{"DARC-WAEDC", "WAEDC", ExSerial},
|
{"DARC-WAEDC", "WAEDC", ExSerial},
|
||||||
|
// EME (Earth-Moon-Earth) contests — the exchange is a signal report (TMO /
|
||||||
|
// RST), so there's no serial or fixed multiplier to prefill.
|
||||||
|
{"ARRL-EME", "ARRL EME Contest", ExRST},
|
||||||
|
{"DUBUS-REF-EME", "Dubus & REF EME Contest", ExRST},
|
||||||
|
{"ARI-EME", "ARI EME Contest", ExRST},
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns the catalogue sorted by name (copy — callers must not mutate).
|
// List returns the catalogue sorted by name (copy — callers must not mutate).
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ type Decoder struct {
|
|||||||
state bool // true = mark (key down)
|
state bool // true = mark (key down)
|
||||||
stateHops int
|
stateHops int
|
||||||
dotHops float64 // adaptive dot length, in hops
|
dotHops float64 // adaptive dot length, in hops
|
||||||
|
markCount int // marks seen since lock (fast WPM adaptation while small)
|
||||||
elem []byte // current "." / "-" run for the in-progress character
|
elem []byte // current "." / "-" run for the in-progress character
|
||||||
charEmitted bool
|
charEmitted bool
|
||||||
wordEmitted bool
|
wordEmitted bool
|
||||||
@@ -145,6 +146,7 @@ func (d *Decoder) Reset() {
|
|||||||
d.state = false
|
d.state = false
|
||||||
d.stateHops = 0
|
d.stateHops = 0
|
||||||
d.dotHops = 15
|
d.dotHops = 15
|
||||||
|
d.markCount = 0
|
||||||
d.elem = d.elem[:0]
|
d.elem = d.elem[:0]
|
||||||
d.charEmitted, d.wordEmitted = false, false
|
d.charEmitted, d.wordEmitted = false, false
|
||||||
}
|
}
|
||||||
@@ -213,10 +215,11 @@ func (d *Decoder) analyze() {
|
|||||||
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we
|
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we
|
||||||
// don't eat the first element of a strong signal), a marginal/weak tone
|
// don't eat the first element of a strong signal), a marginal/weak tone
|
||||||
// locks after a couple of stable hops (so we don't lock onto pure noise).
|
// locks after a couple of stable hops (so we don't lock onto pure noise).
|
||||||
if snr > d.strongSNR || (d.candHops >= 3 && snr > d.acqSNR) {
|
if snr > d.strongSNR || (d.candHops >= 2 && snr > d.acqSNR) {
|
||||||
d.lockIdx = maxIdx
|
d.lockIdx = maxIdx
|
||||||
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
|
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
|
||||||
d.quietHops = 0
|
d.quietHops = 0
|
||||||
|
d.markCount = 0 // relearn WPM fast for this new signal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if d.lockIdx >= 0 {
|
if d.lockIdx >= 0 {
|
||||||
@@ -267,6 +270,17 @@ func (d *Decoder) step() {
|
|||||||
} else {
|
} else {
|
||||||
d.quietHops++
|
d.quietHops++
|
||||||
if d.quietHops > d.relockHops {
|
if d.quietHops > d.relockHops {
|
||||||
|
// End of the over: flush any pending character and drop a word
|
||||||
|
// space so the next transmission starts a fresh word (the word-gap
|
||||||
|
// timer above can't fire once the lock is gone).
|
||||||
|
if len(d.elem) > 0 && !d.charEmitted {
|
||||||
|
d.flushChar()
|
||||||
|
d.charEmitted = true
|
||||||
|
}
|
||||||
|
if !d.wordEmitted && d.onChar != nil {
|
||||||
|
d.onChar(" ")
|
||||||
|
d.wordEmitted = true
|
||||||
|
}
|
||||||
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
|
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,9 +325,14 @@ func (d *Decoder) endMark(hops int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
|
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
|
||||||
// to ~5–100 WPM).
|
// to ~5–60 WPM). The EMA is deliberately GENTLE (0.2) and NOT accelerated on the
|
||||||
|
// opening marks: a fast alpha let short noise blips (misclassified as dots) drag
|
||||||
|
// the dot-length down to the clamp within a few marks — the "60 WPM, all dits"
|
||||||
|
// garbage. The slow EMA is self-correcting because genuine marks pull it back up.
|
||||||
func (d *Decoder) adaptDot(obs float64) {
|
func (d *Decoder) adaptDot(obs float64) {
|
||||||
d.dotHops = d.dotHops*0.8 + obs*0.2 // slower: a few odd marks can't yank it
|
const alpha = 0.2
|
||||||
|
d.markCount++
|
||||||
|
d.dotHops = d.dotHops*(1-alpha) + obs*alpha
|
||||||
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
|
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
|
||||||
d.dotHops = 5
|
d.dotHops = 5
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,11 +303,12 @@ func normalizeCallsign(s string) string {
|
|||||||
if p == "" {
|
if p == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// A TRAILING /MM (maritime) or /AM (aeronautical) mobile is stripped and
|
// A TRAILING /MM (maritime) or /AM (aeronautical) mobile, or /B (beacon), is
|
||||||
// the operator's home entity is kept, so the contact still resolves to a
|
// stripped and the operator's home entity is kept, so the contact still
|
||||||
// country in the log (e.g. YB1SCY/AM → Indonesia). A LEADING "MM" is the
|
// resolves to a country in the log (e.g. YB1SCY/AM → Indonesia, 4U1UN/B →
|
||||||
// Scotland operating prefix (MM/F4NIE) and must NOT be stripped.
|
// 4U1UN → United Nations HQ). A LEADING "MM"/"B" is a PREFIX (MM = Scotland,
|
||||||
if i > 0 && (p == "MM" || p == "AM") {
|
// B = China: B/F4NIE) and must NOT be stripped.
|
||||||
|
if i > 0 && (p == "MM" || p == "AM" || p == "B") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if suffixModifiers[p] {
|
if suffixModifiers[p] {
|
||||||
|
|||||||
@@ -224,6 +224,8 @@ func TestNormalize(t *testing.T) {
|
|||||||
"MM/LY3X/P": "MM",
|
"MM/LY3X/P": "MM",
|
||||||
"F4BPO/W6": "W6",
|
"F4BPO/W6": "W6",
|
||||||
"VK9/F4BPO": "VK9",
|
"VK9/F4BPO": "VK9",
|
||||||
|
"4U1UN/B": "4U1UN", // trailing /B = beacon → strip, keep the base call
|
||||||
|
"B/F4BPO": "B", // leading B = China operating prefix, NOT a beacon
|
||||||
}
|
}
|
||||||
for in, want := range cases {
|
for in, want := range cases {
|
||||||
if got := normalizeCallsign(in); got != want {
|
if got := normalizeCallsign(in); got != want {
|
||||||
|
|||||||
@@ -29,11 +29,14 @@ const (
|
|||||||
type ServiceType string
|
type ServiceType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ServiceWSJT ServiceType = "wsjt" // WSJT-X / JTDX / MSHV binary
|
ServiceWSJT ServiceType = "wsjt" // WSJT-X / JTDX / MSHV binary (inbound)
|
||||||
ServiceADIF ServiceType = "adif" // text ADIF over UDP
|
ServiceADIF ServiceType = "adif" // text ADIF over UDP (inbound)
|
||||||
ServiceN1MM ServiceType = "n1mm" // N1MM Logger+ XML
|
ServiceN1MM ServiceType = "n1mm" // N1MM Logger+ XML (inbound)
|
||||||
ServiceRemoteCall ServiceType = "remote_call" // plain text callsign
|
ServiceRemoteCall ServiceType = "remote_call" // plain text callsign (inbound)
|
||||||
ServiceDBUpdated ServiceType = "db_updated" // outbound ADIF of local QSO
|
// Outbound emitters.
|
||||||
|
ServiceDBUpdated ServiceType = "db_updated" // ADIF of each locally-logged QSO (on save)
|
||||||
|
ServicePstFreq ServiceType = "pstrotator_freq" // <PST><FREQUENCY> radio freq (on freq change)
|
||||||
|
ServiceN1MMRadio ServiceType = "n1mm_radioinfo" // N1MM RadioInfo XML: freq+mode (on freq/mode change)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is one user-defined UDP connection.
|
// Config is one user-defined UDP connection.
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package udp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file holds the outbound emitters: OpsLog → other programs over UDP.
|
||||||
|
// Formats are chosen per connection row (like the inbound parsers), so the user
|
||||||
|
// can point PstRotator, a second logger, an SDR, etc. at OpsLog.
|
||||||
|
|
||||||
|
// tensOfHz converts a frequency in Hz to the "tens of Hz" unit N1MM and
|
||||||
|
// PstRotator use for their frequency fields (e.g. 14 025 500 Hz → 1 402 550).
|
||||||
|
func tensOfHz(freqHz int64) int64 { return freqHz / 10 }
|
||||||
|
|
||||||
|
// BuildPstFreq builds the datagram PstRotatorAz expects for its "DXLog.net"
|
||||||
|
// tracker: <PST><FREQUENCY>{tens of Hz}</FREQUENCY></PST>. Verified by probing a
|
||||||
|
// live PstRotatorAz — it reads the value as tens of Hz (14025.5 kHz → 1402550 →
|
||||||
|
// displayed 3.5255 MHz for 3525.5 kHz, etc.).
|
||||||
|
func BuildPstFreq(freqHz int64) []byte {
|
||||||
|
return []byte(fmt.Sprintf("<PST><FREQUENCY>%d</FREQUENCY></PST>", tensOfHz(freqHz)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildN1MMRadioInfo builds an N1MM Logger+ RadioInfo XML datagram. <Freq> and
|
||||||
|
// <TXFreq> are in tens of Hz. Consumed by PstRotator (as the "N1MM Logger"
|
||||||
|
// tracker) and many other programs. mode is passed through (CW/USB/LSB/…).
|
||||||
|
func BuildN1MMRadioInfo(station string, rxFreqHz, txFreqHz int64, mode, opCall string) []byte {
|
||||||
|
if station == "" {
|
||||||
|
station = "OPSLOG"
|
||||||
|
}
|
||||||
|
if txFreqHz == 0 {
|
||||||
|
txFreqHz = rxFreqHz
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`<?xml version="1.0" encoding="utf-8"?>`)
|
||||||
|
b.WriteString(`<RadioInfo>`)
|
||||||
|
b.WriteString(`<StationName>` + xmlEsc(station) + `</StationName>`)
|
||||||
|
b.WriteString(`<RadioNr>1</RadioNr>`)
|
||||||
|
b.WriteString(fmt.Sprintf(`<Freq>%d</Freq>`, tensOfHz(rxFreqHz)))
|
||||||
|
b.WriteString(fmt.Sprintf(`<TXFreq>%d</TXFreq>`, tensOfHz(txFreqHz)))
|
||||||
|
b.WriteString(`<Mode>` + xmlEsc(mode) + `</Mode>`)
|
||||||
|
b.WriteString(`<OpCall>` + xmlEsc(opCall) + `</OpCall>`)
|
||||||
|
b.WriteString(`<IsRunning>True</IsRunning>`)
|
||||||
|
b.WriteString(`<FocusEntry>0</FocusEntry>`)
|
||||||
|
b.WriteString(`<Antenna>0</Antenna>`)
|
||||||
|
b.WriteString(`<Rotors></Rotors>`)
|
||||||
|
b.WriteString(`<FocusRadioNr>1</FocusRadioNr>`)
|
||||||
|
b.WriteString(`<IsStereo>False</IsStereo>`)
|
||||||
|
b.WriteString(`<ActiveRadioNr>1</ActiveRadioNr>`)
|
||||||
|
b.WriteString(`</RadioInfo>`)
|
||||||
|
return []byte(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func xmlEsc(s string) string {
|
||||||
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RadioState is a snapshot the app pushes to EmitRadioState on freq/mode change.
|
||||||
|
type RadioState struct {
|
||||||
|
StationName string
|
||||||
|
OpCall string
|
||||||
|
RxFreqHz int64 // operating/RX frequency
|
||||||
|
TxFreqHz int64 // TX frequency (may equal RX when not split)
|
||||||
|
Mode string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmitRadioState sends the current radio frequency/mode to every enabled
|
||||||
|
// outbound row whose format is frequency-based (PstRotator, N1MM RadioInfo).
|
||||||
|
// Best-effort: send errors are logged, never returned to the caller.
|
||||||
|
func (m *Manager) EmitRadioState(st RadioState) {
|
||||||
|
for _, c := range m.Outbound(ServicePstFreq) {
|
||||||
|
m.sendTo(c, BuildPstFreq(st.RxFreqHz))
|
||||||
|
}
|
||||||
|
for _, c := range m.Outbound(ServiceN1MMRadio) {
|
||||||
|
m.sendTo(c, BuildN1MMRadioInfo(st.StationName, st.RxFreqHz, st.TxFreqHz, st.Mode, st.OpCall))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmitLoggedADIF sends the ADIF of a just-logged QSO to every enabled outbound
|
||||||
|
// "ADIF message" row (db_updated) — lets a second logger or Cloudlog gateway
|
||||||
|
// pick up contacts as they're logged.
|
||||||
|
func (m *Manager) EmitLoggedADIF(adif string) {
|
||||||
|
if strings.TrimSpace(adif) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range m.Outbound(ServiceDBUpdated) {
|
||||||
|
m.sendTo(c, []byte(adif))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendTo resolves the row's destination (host:port) and fires one datagram.
|
||||||
|
func (m *Manager) sendTo(c Config, payload []byte) {
|
||||||
|
host := strings.TrimSpace(c.DestinationIP)
|
||||||
|
if host == "" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
dst := fmt.Sprintf("%s:%d", host, c.Port)
|
||||||
|
if err := SendUDP(dst, payload); err != nil {
|
||||||
|
applog.Printf("udp: [%s] outbound send to %s failed: %v", c.Name, dst, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,16 @@ type Station struct {
|
|||||||
DXCC int `json:"dxcc,omitempty"`
|
DXCC int `json:"dxcc,omitempty"`
|
||||||
ITU int `json:"itu,omitempty"`
|
ITU int `json:"itu,omitempty"`
|
||||||
CQ int `json:"cq,omitempty"`
|
CQ int `json:"cq,omitempty"`
|
||||||
|
// Full lookup detail so a roster contact carries EVERYTHING into the QSO when
|
||||||
|
// put on air (was previously only name/qth — grid/address/etc. were lost).
|
||||||
|
Grid string `json:"grid,omitempty"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
Cnty string `json:"cnty,omitempty"`
|
||||||
|
Cont string `json:"cont,omitempty"`
|
||||||
|
Lat float64 `json:"lat,omitempty"`
|
||||||
|
Lon float64 `json:"lon,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
Groups string `json:"groups,omitempty"`
|
Groups string `json:"groups,omitempty"`
|
||||||
SIG string `json:"sig,omitempty"`
|
SIG string `json:"sig,omitempty"`
|
||||||
SIGInfo string `json:"sig_info,omitempty"`
|
SIGInfo string `json:"sig_info,omitempty"`
|
||||||
|
|||||||
@@ -46,6 +46,28 @@ func (r *Repo) List(ctx context.Context) ([]Record, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListFor returns the templates VISIBLE to a profile: its own plus any shared
|
||||||
|
// (NULL-profile) ones — so each profile sees and defaults independently instead
|
||||||
|
// of one global list. Defaults first, then newest first.
|
||||||
|
func (r *Repo) ListFor(ctx context.Context, profileID int64) ([]Record, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT `+repoCols+`
|
||||||
|
FROM qsl_templates WHERE profile_id = ? OR profile_id IS NULL
|
||||||
|
ORDER BY is_default DESC, id DESC`, profileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Record
|
||||||
|
for rows.Next() {
|
||||||
|
rec, err := scanRecord(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// Get returns one template by ID, or sql.ErrNoRows if missing.
|
// Get returns one template by ID, or sql.ErrNoRows if missing.
|
||||||
func (r *Repo) Get(ctx context.Context, id int64) (Record, error) {
|
func (r *Repo) Get(ctx context.Context, id int64) (Record, error) {
|
||||||
row := r.db.QueryRowContext(ctx, `SELECT `+repoCols+`
|
row := r.db.QueryRowContext(ctx, `SELECT `+repoCols+`
|
||||||
@@ -124,10 +146,15 @@ func (r *Repo) SetDefault(ctx context.Context, id int64) error {
|
|||||||
// DefaultFor returns the best template for a profile: its own default, then
|
// DefaultFor returns the best template for a profile: its own default, then
|
||||||
// any profile-less default, then the newest template. sql.ErrNoRows if none.
|
// any profile-less default, then the newest template. sql.ErrNoRows if none.
|
||||||
func (r *Repo) DefaultFor(ctx context.Context, profileID int64) (Record, error) {
|
func (r *Repo) DefaultFor(ctx context.Context, profileID int64) (Record, error) {
|
||||||
|
// Only the profile's OWN templates or shared (NULL) ones — never another
|
||||||
|
// profile's, so a profile with no default doesn't inherit some other
|
||||||
|
// profile's newest template (the "default looked global" bug).
|
||||||
row := r.db.QueryRowContext(ctx, `SELECT `+repoCols+` FROM qsl_templates
|
row := r.db.QueryRowContext(ctx, `SELECT `+repoCols+` FROM qsl_templates
|
||||||
|
WHERE profile_id = ? OR profile_id IS NULL
|
||||||
ORDER BY (is_default = 1 AND profile_id = ?) DESC,
|
ORDER BY (is_default = 1 AND profile_id = ?) DESC,
|
||||||
(is_default = 1 AND profile_id IS NULL) DESC,
|
(is_default = 1 AND profile_id IS NULL) DESC,
|
||||||
id DESC LIMIT 1`, profileID)
|
(profile_id = ?) DESC,
|
||||||
|
id DESC LIMIT 1`, profileID, profileID, profileID)
|
||||||
return scanRecord(row)
|
return scanRecord(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ type QSOBox struct {
|
|||||||
H float64 `json:"h"`
|
H float64 `json:"h"`
|
||||||
BG string `json:"bg"`
|
BG string `json:"bg"`
|
||||||
BGOpacity float64 `json:"bg_opacity"`
|
BGOpacity float64 `json:"bg_opacity"`
|
||||||
|
FG string `json:"fg,omitempty"` // text colour (default dark)
|
||||||
Radius float64 `json:"radius"`
|
Radius float64 `json:"radius"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Fields []string `json:"fields"`
|
Fields []string `json:"fields"`
|
||||||
|
|||||||
@@ -723,6 +723,26 @@ var bulkEditableCols = map[string]bool{
|
|||||||
"my_antenna": true,
|
"my_antenna": true,
|
||||||
"my_sig": true,
|
"my_sig": true,
|
||||||
"my_sig_info": true,
|
"my_sig_info": true,
|
||||||
|
// Contest — the exchange/label fields that are constant across a run.
|
||||||
|
// (srx/stx serial numbers stay excluded: they are per-QSO.)
|
||||||
|
"contest_id": true,
|
||||||
|
"srx_string": true,
|
||||||
|
"stx_string": true,
|
||||||
|
"arrl_sect": true,
|
||||||
|
"precedence": true,
|
||||||
|
"class": true,
|
||||||
|
// Propagation / satellite — usually identical across a session.
|
||||||
|
"prop_mode": true,
|
||||||
|
"sat_name": true,
|
||||||
|
"sat_mode": true,
|
||||||
|
// Contacted station activation refs / SIG — meaningful to set across a
|
||||||
|
// selected batch (e.g. a park/summit worked repeatedly).
|
||||||
|
"pota_ref": true,
|
||||||
|
"sota_ref": true,
|
||||||
|
"wwff_ref": true,
|
||||||
|
"iota": true,
|
||||||
|
"sig": true,
|
||||||
|
"sig_info": true,
|
||||||
// Misc text
|
// Misc text
|
||||||
"comment": true,
|
"comment": true,
|
||||||
"notes": true,
|
"notes": true,
|
||||||
|
|||||||
+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.16.5"
|
appVersion = "0.19.1"
|
||||||
|
|
||||||
// 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