22 Commits
Author SHA1 Message Date
rouggy f665d666d6 chore: release v0.19.1 2026-07-08 23:37:29 +02:00
rouggy 5f9ee9efa2 fix: Added missing field for bulk edit 2026-07-08 09:26:30 +02:00
rouggy 44d276c841 fix: Resizable worked before Net Control 2026-07-08 00:16:00 +02:00
rouggy e656c79589 fix: Hiding the alert bell if not active alerts 2026-07-07 22:43:02 +02:00
rouggy 86cbe94cae feat: Added worked before in Net Control 2026-07-07 22:39:17 +02:00
rouggy b9b005ea36 fix: Ultrabeam was not synchronizing after click on a spot. 2026-07-07 21:54:53 +02:00
rouggy 2c47c5d052 feat: Added additional settings in QSL Design on QSO box (opacity, bg color...) 2026-07-07 21:46:52 +02:00
rouggy 446888a330 fix: proper alignment of the version number next to OpsLog 2026-07-07 21:41:35 +02:00
rouggy 128364260b fix: bug when deleting an award reference which was not saved. 2026-07-07 21:08:26 +02:00
rouggy daf38554a5 feat: Award column can now show either reference or description 2026-07-07 20:23:18 +02:00
rouggy 39f095ae75 feat: Alerts are persistent for the 3 last ones for 10minutes 2026-07-07 19:52:52 +02:00
rouggy ea8e954eef chore: release v0.19 2026-07-07 19:35:50 +02:00
rouggy 6db90abcad feat: Management of multiple slices on Flexradio 2026-07-07 19:35:25 +02:00
rouggy 6b9307b427 chore: release v0.18 2026-07-07 17:12:10 +02:00
rouggy ab06673854 feat: added APF for CW in Icom 2026-07-07 17:10:42 +02:00
rouggy 1cadefd207 feat: New buttons for Icom Scope 2026-07-07 16:50:26 +02:00
rouggy 05fbb8a3bc feat: Icom split auto 2026-07-07 15:56:14 +02:00
rouggy 3d88d8f50f fix: /B was not recognized in the cluster 2026-07-07 13:42:59 +02:00
rouggy 5b37397a64 fix: Save and close taking time when Flex or Icom is not connected 2026-07-07 11:07:16 +02:00
rouggy 1184a675c2 fix: callsign color in the tables while changing themes 2026-07-07 10:27:54 +02:00
rouggy 7a24282aa4 feat: Implemented scope on Ethernet for Icom 2026-07-07 09:31:53 +02:00
rouggy 701e8a2c25 feat: Implemented Icom Ethernet CAT control 2026-07-06 17:37:25 +02:00
41 changed files with 3677 additions and 468 deletions
+300
View File
@@ -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 F1F6 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).*
+125 -18
View File
@@ -1,10 +1,13 @@
# OpsLog
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
(OmniRig **and** native FlexRadio/SmartSDR), DX cluster, awards tracking, maps,
QSL management and a QSL-card designer. Built with **Wails v2** (Go backend +
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
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,
optional **shared MySQL** for the logbook so several operators can run one log.
Fully themeable and bilingual (English / French).
Developed by **F4BPO**.
@@ -30,10 +33,16 @@ Developed by **F4BPO**.
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
and the QRZ.com tab.
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
with `/MM` `/AM` and call-area (`/8`, `/W6`) handling, plus ClubLog DXpedition
date overrides.
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
ClubLog DXpedition date overrides.
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
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
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*): 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
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
the **antenna beam lobe(s)** drawn from the rotor azimuth.
@@ -58,15 +68,29 @@ Developed by **F4BPO**.
pane, with a show/hide toggle.
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
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
- **OmniRig** backend (Rig 1/2, hot-swap), and a native **FlexRadio (SmartSDR)**
backend over the radio's TCP API — real-time slice freq/mode/split, auto
reconnect, UDP discovery, and **panadapter spots** (cluster spots pushed to the
Flex display, click → fill the call).
- Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred
from the frequency.
Four native backends (Settings → CAT), each with auto-reconnect and a fast,
non-blocking connect so a powered-off radio never freezes the app:
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
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)
@@ -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
(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
- **WinKeyer** CW keyer (macros, F-key macros, auto-call repeat).
- **Digital Voice Keyer** (DVK) message playback.
- **QSO audio recording** (SSB/DAX) archived per QSO; disabled for CW (no DAX
audio in CW).
- **CW keyer** with macros and F-key macros. The keyer engine is selectable:
**WinKeyer** (K1EL WK1/2/3 over a COM port), **Icom** (the radio's own keyer
over CI-V — no extra hardware, works over the remote link too) or **TCI**.
- **Digital Voice Keyer** (DVK): record F1F6 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
- **Awards engine:** built-in + custom award definitions (shared **globally**
across profiles), worked/confirmed/validated by band & mode, OR rules and
manual reference assignment, live reference detection on call entry, and a
**Rescan** that re-pulls the logbook (picks up fresh LoTW/QRZ confirmations).
across profiles) — DXCC, WAS / WAZ / WAC, WPX, IOTA / POTA / SOTA / WWFF,
**DDFM**, worked/confirmed/validated by band & mode, OR rules and manual
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
and **confirmation download** (which auto-refreshes the award stats).
- **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
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)
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
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
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
OpsLog startup, skipping any already running.
- **Backup:** optional database + ADIF backup at shutdown.
- **Update check** at startup with a toast (toggleable).
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
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.
- **Logbook** (QSOs) lives where the active profile points it: the local SQLite
file or a per-profile shared **MySQL** database.
---
*A French version of this document is available in [README.fr.md](README.fr.md).*
+248 -4
View File
@@ -93,6 +93,9 @@ const (
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
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)
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama
@@ -265,7 +268,7 @@ type QSLDefaults struct {
// individual key/value pairs to keep the settings table flat.
type CATSettings struct {
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)
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
@@ -273,6 +276,9 @@ type CATSettings struct {
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)
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)
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
@@ -1107,6 +1113,13 @@ func (a *App) shutdown(ctx context.Context) {
if a.udp != nil {
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 {
a.winkeyer.Disconnect()
}
@@ -2893,8 +2906,10 @@ func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error)
defs := a.awardDefs()
metas := a.awardRefMetas(defs)
fieldByCode := map[string]string{}
dispByCode := map[string]string{}
for _, d := range defs {
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 {
switch field {
@@ -2920,11 +2935,23 @@ func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error)
if !rf.Worked {
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
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 != "" {
label = rf.Name
}
}
refs = append(refs, label)
}
if len(refs) > 0 {
@@ -3602,6 +3629,24 @@ var bulkFieldColumns = map[string]string{
"my_antenna": "my_antenna",
"my_sig": "my_sig",
"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
"comment": "comment",
"notes": "notes",
@@ -4276,7 +4321,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil {
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 {
return CATSettings{}, err
}
@@ -4290,6 +4335,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
IcomPort: m[keyCATIcomPort],
IcomBaud: 115200,
IcomAddr: 0x98, // IC-7610 default
IcomNetHost: m[keyCATIcomNetHost],
IcomNetUser: m[keyCATIcomNetUser],
IcomNetPass: m[keyCATIcomNetPass],
TCIHost: m[keyCATTCIHost],
TCIPort: 40001,
TCISpots: m[keyCATTCISpots] == "1",
@@ -4378,6 +4426,9 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
keyCATIcomNetHost: strings.TrimSpace(s.IcomNetHost),
keyCATIcomNetUser: strings.TrimSpace(s.IcomNetUser),
keyCATIcomNetPass: s.IcomNetPass,
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
keyCATTCIPort: strconv.Itoa(s.TCIPort),
keyCATTCISpots: tciSpots,
@@ -4961,6 +5012,8 @@ func (a *App) NetLookup(callsign string) netctl.Station {
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.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
}
@@ -5051,6 +5104,16 @@ func (a *App) NetActivate(callsign string) (qso.QSO, error) {
for _, st := range net.Stations {
if strings.EqualFold(st.Callsign, call) {
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 {
d := st.DXCC
q.DXCC = &d
@@ -7729,9 +7792,54 @@ func (a *App) SetCATFrequency(hz int64) error {
if err != nil {
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
}
// 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
// translated to backend-specific values by the backend itself.
func (a *App) SetCATMode(mode string) error {
@@ -7824,6 +7932,13 @@ func (a *App) IcomSetANF(on bool) error {
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 {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
@@ -7873,6 +7988,97 @@ func (a *App) IcomSetSplit(on bool) error {
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 {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
@@ -7880,6 +8086,15 @@ func (a *App) IcomTune() error {
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.
func (a *App) IcomSetScope(on bool) error {
if a.cat == nil {
@@ -7905,6 +8120,15 @@ func (a *App) IcomSetScopeMode(fixed bool) error {
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 {
@@ -8135,6 +8359,22 @@ func (a *App) FlexSetSplit(on bool) error {
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).
const keyFlexBandAnt = "flex.band_antennas"
@@ -8349,8 +8589,12 @@ func (a *App) reloadCAT() {
a.cat.Start(fb)
case "icom":
// 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))
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":
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
// TCI-compatible server.
+10 -2
View File
@@ -122,12 +122,20 @@ func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) {
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) {
if a.qslTemplates == nil {
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 {
return nil, err
}
+401 -187
View File
@@ -1,23 +1,21 @@
// Command icomnettest is an iteration probe for the Icom IP remote protocol
// (the LAN server built into the IC-7610 the one RS-BA1 and wfview talk to).
// We're reimplementing it from the public protocol description, so this tool
// drives the CONTROL stream (default UDP 50001) and hex-dumps every packet both
// ways, letting us confirm the framing / type codes against the real rig before
// folding it into internal/cat/icomnet. Nothing here is copied from wfview
// (GPLv3) — it's a clean-room implementation from the protocol structure.
// 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 first milestone is the CONNECTION HANDSHAKE only (no login yet):
// areYouThere → iAmHere → areYouReady → iAmReady → periodic idle pings.
// Watch the log: if the rig answers our areYouThere we've got the framing right;
// its reply reveals the remote station ID we echo back. Login (token + user/
// password) is the next step once the handshake is confirmed.
// 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 192.168.1.60 # control port 50001
// go run ./cmd/icomnettest 192.168.1.60 50001 20 # port + run seconds
// go run ./cmd/icomnettest <rig-ip> <user> <pass> [compname]
//
// SAFE: only the control stream, no CI-V commands, no TX — it just opens and
// pings, then disconnects. Share the log and we iterate.
// SAFE: read-only CI-V (operating frequency). No TX, no writes.
package main
import (
@@ -26,83 +24,59 @@ import (
"fmt"
"net"
"os"
"strconv"
"time"
)
// Control-stream packet types (best-known values from the public protocol
// description — the very thing we're verifying with this probe).
const (
typeAreYouThere = 0x03
typeIAmHere = 0x04
typeDisconnect = 0x05
typeAreYouReady = 0x06 // same type both directions (areYouReady / iAmReady)
typeIdle = 0x00 // 16-byte keepalive (retransmit/ack carrier)
typePing = 0x07 // 21-byte ping (offset 16 = 0x00 request / 0x01 reply, +4-byte payload)
)
var le = binary.LittleEndian
var be = binary.BigEndian
// ctrlPacket is the 16-byte common control packet, all fields little-endian:
//
// uint32 len (=0x10) · uint16 type · uint16 seq · uint32 sentid · uint32 rcvdid
func ctrlPacket(typ uint16, seq uint16, sentid, rcvdid uint32) []byte {
b := make([]byte, 16)
binary.LittleEndian.PutUint32(b[0:], 0x10)
binary.LittleEndian.PutUint16(b[4:], typ)
binary.LittleEndian.PutUint16(b[6:], seq)
binary.LittleEndian.PutUint32(b[8:], sentid)
binary.LittleEndian.PutUint32(b[12:], rcvdid)
return b
}
// passcodeSeq is Icom's fixed obfuscation table for the login username/password
// (used by RS-BA1). BEST-EFFORT public reconstruction — the values that matter
// for a given credential are sequence[char+index]; if the radio rejects auth,
// compare the "scrambled" bytes this tool prints against a real login capture to
// correct the needed entries.
// 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{
0x47, 0x5d, 0x4c, 0x42, 0x66, 0x20, 0x23, 0x46, 0x4e, 0x57, 0x45, 0x3d, 0x67, 0x76, 0x60, 0x41,
0x62, 0x39, 0x59, 0x2d, 0x68, 0x7e, 0x20, 0x77, 0x5f, 0x51, 0x3e, 0x70, 0x4d, 0x1f, 0x74, 0x38,
0x2c, 0x4b, 0x1e, 0x54, 0x30, 0x71, 0x2b, 0x2a, 0x66, 0x27, 0x2e, 0x58, 0x24, 0x21, 0x2f, 0x50,
0x1b, 0x73, 0x69, 0x36, 0x1d, 0x4f, 0x1c, 0x51, 0x2e, 0x1e, 0x45, 0x2e, 0x22, 0x50, 0x64, 0x66,
0x24, 0x36, 0x0c, 0x7d, 0x50, 0x25, 0x7c, 0x3f, 0x2d, 0x35, 0x71, 0x6a, 0x0e, 0x41, 0x2a, 0x67,
0x7c, 0x64, 0x77, 0x67, 0x6d, 0x5b, 0x3d, 0x5b, 0x2b, 0x67, 0x6c, 0x39, 0x35, 0x76, 0x3b, 0x2f,
0x2f, 0x6d, 0x59, 0x6e, 0x59, 0x77, 0x3b, 0x24, 0x74, 0x7c, 0x6b, 0x37, 0x54, 0x5c, 0x4d, 0x1f,
0x27, 0x69, 0x5b, 0x2e, 0x28, 0x35, 0x77, 0x74, 0x35, 0x1f, 0x6a, 0x2a, 0x28, 0x30, 0x25, 0x20,
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,
}
// passcode scrambles s (username or password) via the Icom sequence table.
func passcode(s string) []byte {
out := make([]byte, len(s))
for i := 0; i < len(s); i++ {
out := make([]byte, 0, len(s))
for i := 0; i < len(s) && i < 16; i++ {
p := int(s[i]) + i
if p > 0x7f {
p = ((p - 0x7f) % 0x33) - 1
if p < 0 {
p = 0
if p > 126 {
p = 32 + p%127
}
}
out[i] = passcodeSeq[p&0xff]
out = append(out, passcodeSeq[p])
}
return out
}
// buildLogin builds the 0x80-byte login packet: control header + username/
// password (scrambled) at 0x40/0x50 and the app name at 0x60. The middle fields
// (payload size, request type, inner seq, token request) are a best-effort
// reconstruction and may need adjustment against a capture.
func buildLogin(seq uint16, sentid, rcvdid uint32, innerSeq, tokRequest uint16, user, pass, name string) []byte {
// --- 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)
binary.LittleEndian.PutUint32(b[0:], 0x80) // len
// type (b[4:6]) = 0x00
binary.LittleEndian.PutUint16(b[6:], seq)
binary.LittleEndian.PutUint32(b[8:], sentid)
binary.LittleEndian.PutUint32(b[12:], rcvdid)
binary.LittleEndian.PutUint32(b[16:], 0x70) // payload size (len - 0x10)
binary.LittleEndian.PutUint16(b[20:], 0x00) // requesttype
binary.LittleEndian.PutUint16(b[22:], 0x01) // requestreply
binary.LittleEndian.PutUint16(b[24:], innerSeq)
binary.LittleEndian.PutUint16(b[26:], tokRequest)
// token (b[0x20:0x24]) = 0 until the rig grants one
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
@@ -113,140 +87,380 @@ func buildLogin(seq uint16, sentid, rcvdid uint32, innerSeq, tokRequest uint16,
return b
}
func parseHeader(b []byte) (length uint32, typ, seq uint16, sentid, rcvdid uint32, ok bool) {
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
}
length = binary.LittleEndian.Uint32(b[0:])
typ = binary.LittleEndian.Uint16(b[4:])
seq = binary.LittleEndian.Uint16(b[6:])
sentid = binary.LittleEndian.Uint32(b[8:])
rcvdid = binary.LittleEndian.Uint32(b[12:])
return length, typ, seq, sentid, rcvdid, true
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) < 2 {
fmt.Println("usage: icomnettest <rig-ip> [user] [password]")
fmt.Println(" <rig-ip> only → handshake + ping probe")
fmt.Println(" <rig-ip> <user> <pass> → also attempt login")
fmt.Println("example: icomnettest 192.168.1.60 f6bgc cgb6f1")
if len(os.Args) < 4 {
fmt.Println("usage: icomnettest <rig-ip> <user> <pass> [compname]")
os.Exit(2)
}
ip := os.Args[1]
port := 50001
runSecs := 25
user, pass := "", ""
if len(os.Args) >= 4 {
user, pass = os.Args[2], os.Args[3]
ip, user, pass := os.Args[1], os.Args[2], os.Args[3]
compName := "OpsLog"
if len(os.Args) >= 5 {
compName = os.Args[4]
}
target := net.JoinHostPort(ip, strconv.Itoa(port))
conn, err := net.Dial("udp4", target)
// ===================== CONTROL STREAM (50001) =====================
ctrl, err := net.Dial("udp4", net.JoinHostPort(ip, "50001"))
if err != nil {
fmt.Printf("dial %s: %v\n", target, err)
fmt.Printf("dial control: %v\n", err)
os.Exit(1)
}
defer conn.Close()
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))
// Our local station ID. Real clients derive it from the local IP:port; a
// stable non-zero value is fine for probing. We'll refine once we see how the
// rig echoes it back.
local := conn.LocalAddr().(*net.UDPAddr)
myID := uint32(local.IP.To4()[0])<<24 | uint32(local.IP.To4()[1])<<16 | uint32(uint16(local.Port))
var remoteID uint32
var seq uint16
logTx := func(name string, p []byte) {
fmt.Printf("TX %-14s (%d bytes)\n%s\n", name, len(p), hex.Dump(p))
if _, err := conn.Write(p); err != nil {
fmt.Printf(" write error: %v\n", err)
}
}
fmt.Printf("Probing Icom control stream at %s (myID=0x%08X)\n\n", target, myID)
if user != "" {
fmt.Printf("Login mode: user=%q pass=%q\n", user, pass)
fmt.Printf(" scrambled user = % X\n", passcode(user))
fmt.Printf(" scrambled pass = % X\n\n", passcode(pass))
cRemote, ok := handshake(ctrl, cID, "ctrl")
if !ok {
fmt.Println("control handshake failed")
return
}
var innerSeq uint16 = 0x0001
var tokRequest uint16 = 0x1234 // fixed for reproducibility (no RNG in this probe)
loginSent := false
// 1) areYouThere — ask the rig to announce itself.
seq++
logTx("areYouThere", ctrlPacket(typeAreYouThere, seq, myID, 0))
// 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++
// Read loop: dump everything, and advance the handshake when we recognise a
// reply. Runs for runSecs then disconnects.
deadline := time.Now().Add(time.Duration(runSecs) * time.Second)
var token uint32
buf := make([]byte, 2048)
lastIdle := time.Now()
readyStarted := false
for time.Now().Before(deadline) {
_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
n, err := conn.Read(buf)
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
// Periodic idle keepalive once connected.
if remoteID != 0 && time.Since(lastIdle) > 100*time.Millisecond {
seq++
logTx("idle", ctrlPacket(typeIdle, seq, myID, remoteID))
lastIdle = time.Now()
}
continue
}
fmt.Printf("read error: %v\n", err)
break
}
pkt := append([]byte(nil), buf[:n]...)
length, typ, rseq, sentid, rcvdid, ok := parseHeader(pkt)
deadline := time.Now().Add(4 * time.Second)
for token == 0 && time.Now().Before(deadline) {
p, ok := recv(ctrl, 200, buf)
if !ok {
fmt.Printf("RX (%d bytes, too short to parse)\n%s\n", n, hex.Dump(pkt))
continue
}
fmt.Printf("RX len=%d type=0x%02X seq=%d sentid=0x%08X rcvdid=0x%08X (%d bytes)\n%s\n",
length, typ, rseq, sentid, rcvdid, n, hex.Dump(pkt))
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
}
switch typ {
case typeIAmHere:
remoteID = sentid // the rig's ID — echo it back as rcvdid from now on
fmt.Printf(">> iAmHere: remoteID=0x%08X — sending areYouReady\n\n", remoteID)
seq++
logTx("areYouReady", ctrlPacket(typeAreYouReady, seq, myID, remoteID))
readyStarted = true
case typeAreYouReady:
if readyStarted && !loginSent {
fmt.Printf(">> iAmReady — control link is up.\n\n")
if user != "" {
seq++
lg := buildLogin(seq, myID, remoteID, innerSeq, tokRequest, user, pass, "OpsLog")
fmt.Printf(">> sending login (user=%q)\n", user)
logTx("login", lg)
loginSent = true
// 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))
}
}
case typePing:
// Reply to the rig's ping: mirror the packet, swap sender/receiver IDs,
// set the reply flag at offset 16. Keeps the link healthy so we can
// observe the connection long enough to work on login.
reply := append([]byte(nil), pkt...)
if len(reply) >= 17 {
binary.LittleEndian.PutUint32(reply[8:], myID)
binary.LittleEndian.PutUint32(reply[12:], remoteID)
reply[16] = 0x01 // request → reply
logTx("pingReply", reply)
}
case typeDisconnect:
fmt.Printf(">> rig sent disconnect\n\n")
}
}
// Clean disconnect.
if remoteID != 0 {
seq++
logTx("disconnect", ctrlPacket(typeDisconnect, seq, myID, remoteID))
// ===================== 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
}
fmt.Println("Done. Paste the log — especially the rig's replies to areYouThere.")
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)
}
+2 -1
View File
@@ -17,6 +17,7 @@
*/
$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_USER = 'opslog';
$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".
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) {
http_response_code(500);
exit('DB error');
+99 -11
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
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,
} from 'lucide-react';
@@ -793,6 +793,22 @@ export default function App() {
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).
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
@@ -889,12 +905,12 @@ export default function App() {
// map ("map1"), the locator street map ("map2"), the cluster grid or the
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
// 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 [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
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([
GetUIPref('mainPaneLeft').catch(() => ''),
GetUIPref('mainPaneRight').catch(() => ''),
@@ -994,11 +1010,15 @@ export default function App() {
// 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).
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(() => {
GetAwardDefs().then((defs: any[]) =>
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
).catch(() => {});
}, []);
}, [awardsVersion]);
const [qsoAwardRefs, setQsoAwardRefs] = useState<Record<string, Record<string, string>>>({});
useEffect(() => {
const ids = (qsos as any[]).map((q) => q.id).filter(Boolean);
@@ -1006,7 +1026,7 @@ export default function App() {
let alive = true;
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {});
return () => { alive = false; };
}, [qsos, awardCols.length]);
}, [qsos, awardCols.length, awardsVersion]);
const qsosWithAwards = useMemo(
() => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })),
[qsos, qsoAwardRefs],
@@ -1018,7 +1038,7 @@ export default function App() {
let alive = true;
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setWbAwardRefs(m ?? {}); }).catch(() => {});
return () => { alive = false; };
}, [wb, awardCols.length]);
}, [wb, awardCols.length, awardsVersion]);
const wbWithAwards = useMemo(
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null),
[wb, wbAwardRefs],
@@ -1197,9 +1217,17 @@ export default function App() {
if (p?.visual) {
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}` : ''}`);
// 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(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showToast]);
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
@@ -2697,6 +2725,49 @@ export default function App() {
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
</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.
const countryBlockSm = (
<div className="flex flex-col w-36">
@@ -3080,6 +3151,18 @@ export default function App() {
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</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':
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
@@ -3136,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">
<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)]" />
<span className="font-bold text-[15px] tracking-tight">OpsLog</span>
<span className="text-[11px] text-muted-foreground cursor-pointer hover:text-foreground" onClick={() => setShowAbout(true)} title="About OpsLog">v{APP_VERSION}</span>
<div className="flex items-baseline gap-1.5">
<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>
<Menubar menus={menus} onAction={handleMenu} />
@@ -3567,13 +3652,15 @@ export default function App() {
</div>
{/* 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. */}
<div className="flex gap-2 items-end">
its right edge lines up with that column below; QTH grows to fill.
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>
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
</div>
{qthBlock}
{gridBlock}
{alertLedBlock}
</div>
{/* Row 3: tight left detail column (Band/Mode/Country) and
@@ -4136,7 +4223,7 @@ export default function App() {
</TabsContent>
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
<AwardsPanel onEditQSO={openEdit} />
<AwardsPanel onEditQSO={openEdit} onAwardsChanged={() => setAwardsVersion((v) => v + 1)} />
</TabsContent>
{contestTabEnabled && (
@@ -4344,6 +4431,7 @@ export default function App() {
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }}
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
flexAvailable={catState.backend === 'flex'}
icomAvailable={catState.backend === 'icom'}
/>
)}
+11
View File
@@ -30,6 +30,7 @@ type RefMeta = { code: string; count: number; updated_at: string; can_update: bo
export type AwardDef = {
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;
ref_display?: string; // grid column shows: ref | name | both
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[];
or_rules?: AwardOrRule[];
@@ -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.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.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">
<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>
+2 -2
View File
@@ -61,7 +61,7 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
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 [awardList, setAwardList] = useState<AwardListItem[]>([]);
// 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')}
</Button>
</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. */}
{awardList.length > 0 && (
<div className="px-2 py-2 border-b border-border/40">
+27 -3
View File
@@ -49,6 +49,24 @@ const FIELDS: FieldDef[] = [
{ 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_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
{ id: 'comment', label: 'bulk.fComment', 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' },
];
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.
const GROUP_LABELS: Record<string, string> = {
'QSL / upload': 'bulk.groupQsl',
'My station': 'bulk.groupMyStation',
'Contacted station': 'bulk.groupContacted',
'Contest': 'bulk.groupContest',
'Propagation': 'bulk.groupPropagation',
'Misc': 'bulk.groupMisc',
};
@@ -130,8 +151,11 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
{GROUPS.map((g) => (
<div key={g}>
<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) => (
<SelectItem key={f.id} value={f.id}>{t(f.label)}</SelectItem>
{FIELDS.filter((f) => f.group === g)
.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>
))}
+10 -5
View File
@@ -124,11 +124,16 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
defaultVisible: true,
cellClass: 'font-mono',
// New DXCC entity → fill the whole cell (no padded pill, so calls stay
// aligned with non-new rows). Text colour also flags worked-call vs new-call.
cellStyle: (p: any): any => (statusFor(p)?.status === 'new'
? { backgroundColor: '#ffe4e6', color: '#be123c', fontWeight: 700 }
: { color: statusFor(p)?.worked_call ? '#0369a1' : '#b8410c', fontWeight: 700 }),
// Only STATUS calls get a colour: new DXCC entity → filled cell (no padded
// pill, so calls stay aligned), worked-call → blue. A plain spot inherits the
// theme's normal text colour (var(--foreground)) so callsigns blend in with
// the rest of the row across every theme instead of always shouting orange.
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) => {
const s = statusFor(p);
return s?.status === 'new' ? t('clg2.tipNewDxcc', { country: s?.country ?? '' }) : s?.worked_call ? t('clg2.tipWorkedCall') : undefined;
+18 -2
View File
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
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 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
// OpsLog has a valid station before any QSO is logged. Not dismissable.
export function FirstRunModal({ onDone }: { onDone: () => void }) {
const { t } = useI18n();
const { t, lang, setLang } = useI18n();
const [p, setP] = useState<Profile | null>(null);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState('');
@@ -68,6 +68,22 @@ export function FirstRunModal({ onDone }: { onDone: () => void }) {
return (
<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">
{/* 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">
<Radio className="size-5 text-primary" />
<h2 className="text-lg font-semibold">{t('frm.welcome')}</h2>
+36 -1
View File
@@ -5,7 +5,7 @@ import {
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
@@ -30,8 +30,10 @@ type FlexState = {
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
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 };
const ZERO: FlexState = {
@@ -280,6 +282,39 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</span>
</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 && (
<div className="text-center text-sm text-muted-foreground py-6">
{t('flxp.waiting')}
+248 -51
View File
@@ -1,12 +1,15 @@
import { useEffect, useRef, useState } from 'react';
import { Radio, AudioLines, RefreshCw, Mic, Activity, SlidersHorizontal } from 'lucide-react';
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna, Filter, Power } from 'lucide-react';
import {
GetIcomState, IcomRefresh,
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
IcomSetANF, IcomSetAPF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
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';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
@@ -18,20 +21,60 @@ type IcomState = {
s_meter: number; power_meter: number; swr_meter: number;
rf_power: number; mic_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;
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 = {
available: false, transmitting: false, split: false,
s_meter: 0, power_meter: 0, swr_meter: 0, rf_power: 0, mic_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,
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 }: {
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number;
}) {
@@ -148,25 +191,6 @@ function Meter({ label, value, accent, scale, onClick, title }: { label: string;
return <div className="flex items-center gap-2">{body}</div>;
}
// HdrMeter — a compact live meter for the model header band (S when receiving,
// Po/SWR when transmitting). Clickable variant sends the S reading to RST tx.
function HdrMeter({ label, value, accent, scale, onClick, title }: {
label: string; value: number; accent: string; scale: string; onClick?: () => void; title?: string;
}) {
const v = Math.max(0, Math.min(100, value));
const body = (
<>
<span className="w-5 shrink-0 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
<div className="w-24 sm:w-32 h-2 rounded-full bg-muted/70 overflow-hidden">
<div className="h-full rounded-full transition-[width] duration-150" style={{ width: `${v}%`, background: accent }} />
</div>
<span className="w-12 text-right text-[11px] font-mono font-bold tabular-nums" style={{ color: accent }}>{scale}</span>
</>
);
if (onClick) return <button type="button" onClick={onClick} title={title} className="flex items-center gap-1.5 rounded-md hover:bg-muted/60 px-1.5 py-0.5 -my-0.5">{body}</button>;
return <div className="flex items-center gap-1.5 px-1.5">{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 }: {
@@ -244,12 +268,31 @@ function ScopePanadapter() {
const holdRef = useRef<number[]>([]); // per-bin peak-hold line
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 centerRef = useRef(0); // scope centre we last set (for pan ◀/▶)
const toggle = () => {
const next = !on;
setOn(next);
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) => {
setFixed(nextFixed);
IcomSetScopeMode(nextFixed).catch(() => {});
@@ -370,19 +413,28 @@ function ScopePanadapter() {
ctx.strokeStyle = '#7dd3fc'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.stroke();
ctx.restore();
// VFO marker: translucent band + crisp line + top marker triangle. In centre
// mode the span tracks the VFO, so fall back to the exact centre when the
// reported freq isn't inside the (just-updated) span — you always see where
// you are.
const inSpan = vfoHz > 0 && lowHz > 0 && highHz > lowHz && vfoHz >= lowHz && vfoHz <= highHz;
const markerX = inSpan ? ((vfoHz - lowHz) / (highHz - lowHz)) * w : (!fixedMode ? w / 2 : -1);
// 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.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. In fixed mode the rig reports usable edge frequencies, so
@@ -448,6 +500,16 @@ function ScopePanadapter() {
<Activity className="size-4" style={{ color: '#38bdf8' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('icmp.spectrum')}</span>
<div className="ml-auto 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 && (
<Segmented value={fixed ? 'FIX' : 'CTR'} options={[{ v: 'CTR', l: 'CTR' }, { v: 'FIX', l: 'FIX' }]}
onChange={(v) => setMode(v === 'FIX')} />
@@ -475,17 +537,21 @@ function ScopePanadapter() {
export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void } = {}) {
const { t } = useI18n();
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 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 () => {
setBusy(true);
try { await IcomRefresh(); } catch {}
await load();
setBusy(false);
};
useEffect(() => {
@@ -543,6 +609,17 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
}
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 (
<div className="h-full min-h-0 overflow-auto bg-background">
@@ -559,26 +636,93 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
{st.mode ? <span className="text-xs font-mono text-muted-foreground">{st.mode}</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>
{/* Live meter in the header band: S when receiving (click → RST tx), Po when transmitting. */}
<div className="hidden md:flex items-center">
{tx ? (
<HdrMeter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter}%`} />
) : (() => { const sp = sParts(st.s_meter); return (
<HdrMeter label="S" value={st.s_meter} accent="#22c55e" scale={sp.label}
<div className="flex items-center gap-1.5">
{/* Radio power ON / OFF. Manual by design the app never wakes the rig
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 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} />
); })()}
</div>
<button type="button" onClick={refresh} disabled={busy}
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">
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('icmp.refresh')}
</button>
<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>
{/* Spectrum panadapter (full width). */}
<ScopePanadapter />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Band buttons + antenna selection. */}
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
<div className="grid grid-cols-5 gap-1.5">
{BANDS.map((b) => (
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
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}
</button>
))}
</div>
<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}
@@ -588,12 +732,6 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
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>
{tx && (
<div className="pt-1 border-t border-border/60 space-y-3">
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter}%`} />
<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>
)}
</Card>
{/* Transmit controls. */}
@@ -602,10 +740,12 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
<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>
</Row>
{isPhone && (
<Row label={t('icmp.mic')}>
<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>
</Row>
)}
<div className="flex items-center gap-2 pt-1">
<button type="button" onClick={toggleMox}
className={cn('flex-1 px-3 py-1.5 rounded-md text-xs font-bold border transition-colors',
@@ -619,6 +759,27 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
TUNE
</button>
</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 icon={Radio} title={t('icmp.receive')} accent="#2563eb">
@@ -630,6 +791,10 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
<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>
</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">
<Segmented value={st.agc || ''} options={[{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }]}
onChange={(v) => set({ agc: v }, () => IcomSetAGC(v))} />
@@ -648,6 +813,31 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
</Row>
</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">
<LevelRow label="NB" on={st.nb} value={st.nb_level}
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
@@ -659,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))} />
<span className="text-xs text-muted-foreground">{t('icmp.autoNotch')}</span>
</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>
</div>
</div>
+92 -1
View File
@@ -5,7 +5,7 @@ import {
} from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme';
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 {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
@@ -20,6 +20,7 @@ import {
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
WorkedBefore,
} from '@/../wailsjs/go/main/App';
import { netctl } from '@/../wailsjs/go/models';
@@ -67,6 +68,36 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
const activeGrid = 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 refreshNets = useCallback(async () => {
@@ -173,6 +204,9 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
...c, callsign: (r.callsign || call).toUpperCase(),
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,
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)); }
finally { setLooking(false); }
@@ -266,6 +300,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
rowData={active}
columnDefs={activeCols}
defaultColDef={defaultColDef}
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
animateRows={false}
@@ -281,6 +316,61 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
</Button>
</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>
{/* NET USERS / roster (right) */}
@@ -298,6 +388,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
columnDefs={rosterCols}
defaultColDef={defaultColDef}
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)}
animateRows={false}
getRowId={(p) => String((p.data as any).callsign)}
+5 -1
View File
@@ -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.
const awardFieldRef = useRef<Record<string, string>>({});
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 }>>([]);
// 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)
.map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`)
.join(';');
seedAwardRefsRef.current = seed;
setAwardRefs(seed);
} 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
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
applyAwardRefs(out, awardRefs, awardFieldRef.current);
applyAwardRefs(out, awardRefs, awardFieldRef.current, seedAwardRefsRef.current);
onSave(out);
}
+1 -1
View File
@@ -92,7 +92,7 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
// ── 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_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_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 },
+41 -11
View File
@@ -146,6 +146,7 @@ interface Props {
onSaved: () => void;
onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update
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
@@ -573,17 +574,19 @@ const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
{ value: 'cluster', label: 'Cluster spots' },
{ value: 'worked', label: 'Worked before' },
{ 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 [right, setRight] = useState('map2');
// FlexRadio is only offered when the CAT backend is a Flex. Sorted A→Z.
const options = (flexAvailable
? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }]
: [...MAIN_PANE_OPTIONS]
).sort((a, b) => a.label.localeCompare(b.label));
// Radio-control panes are only offered when that CAT backend is active. Sorted A→Z.
const options = [
...MAIN_PANE_OPTIONS,
...(flexAvailable ? [{ value: 'flex', label: 'FlexRadio controls' }] : []),
...(icomAvailable ? [{ value: 'icom', label: 'Icom console' }] : []),
].sort((a, b) => a.label.localeCompare(b.label));
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(() => '')])
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
}, []);
@@ -756,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 [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
const [loading, setLoading] = useState(true);
@@ -793,7 +796,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({
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',
});
const [rotator, setRotator] = useState<RotatorSettings>({
@@ -1967,6 +1971,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectItem value="omnirig">{t('cat.optOmnirig')}</SelectItem>
<SelectItem value="flex">{t('cat.optFlex')}</SelectItem>
<SelectItem value="icom">{t('cat.optIcom')}</SelectItem>
<SelectItem value="icom-net">{t('cat.optIcomNet')}</SelectItem>
<SelectItem value="tci">{t('cat.optTci')}</SelectItem>
</SelectContent>
</Select>
@@ -2037,6 +2042,31 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</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' && (
<>
<div className="space-y-1">
@@ -2058,7 +2088,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</label>
</>
)}
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && (
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom' || catCfg.backend === 'icom-net') && (
<>
<div className="space-y-1">
<Label>{t('cat.pollMs')}</Label>
@@ -3904,7 +3934,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<TelemetryToggle />
<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">
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
+5 -4
View File
@@ -292,6 +292,7 @@ const QSO_FIELD_WEIGHT: Record<string, number> = {
// QSOBoxView renders the confirmation box with per-QSO values.
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 total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
let cursor = 28;
@@ -304,24 +305,24 @@ function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, strin
return (
<>
<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">
{box.title}
</text>
{cols.map(({ f, x }) => (
<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">
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
</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">
{values[f] ?? ''}
</text>
</g>
))}
{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">
{box.footer}
</text>
@@ -170,6 +170,35 @@ export function EditorPanel({ template, sel, presets, fontFamilies, onPatchEleme
onChange={(w) => onPatchBox({ w })} />
<NumberField label="Height" value={box.h} min={120} max={500}
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">
<Label className="text-xs text-muted-foreground">Footer</Label>
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
+1
View File
@@ -118,6 +118,7 @@ export interface QSOBox {
h: number;
bg: string;
bg_opacity: number;
fg?: string; // text colour (default dark); set it when using a dark background
radius: number;
title: string;
fields: string[];
+30 -1
View File
@@ -33,6 +33,19 @@ function appendTokens(existing: string | undefined, refs: string): string {
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
// 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
@@ -47,8 +60,24 @@ const MANUAL_REFS_KEY = 'APP_OPSLOG_AWARDREFS';
// 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
// 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);
// 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 overrides: string[] = [];
for (const [code, ref] of Object.entries(byCode)) {
+18 -12
View File
@@ -21,6 +21,7 @@ const en: Dict = {
'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.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…',
// Duplicates modal
'dup.title': 'Find duplicates', 'dup.scanning': 'Scanning the log…', 'dup.none': 'No duplicates found. 🎉',
@@ -130,7 +131,9 @@ const en: Dict = {
'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 12 min delay so a mis-logged QSO can still be fixed first).',
'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
// 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.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)",
@@ -189,15 +192,15 @@ const en: Dict = {
'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 F1F6.', '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.',
'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',
'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.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active',
'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',
'qrz.openTitle': 'Open {call} on QRZ.com',
// 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',
'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': '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',
'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',
@@ -205,10 +208,10 @@ const en: Dict = {
'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.',
'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)
'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}',
'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)
@@ -226,6 +229,7 @@ const fr: Dict = {
'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.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…',
'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.',
@@ -320,7 +324,9 @@ const fr: Dict = {
'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 12 min pour corriger un QSO mal saisi avant).",
'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.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)",
@@ -373,23 +379,23 @@ const fr: Dict = {
'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 F1F6.', '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.',
'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',
'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.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif',
'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',
'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',
'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': "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',
'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.',
'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.',
'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…',
'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}',
'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é',
+1 -1
View File
@@ -1,6 +1,6 @@
// 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).
export const APP_VERSION = '0.17';
export const APP_VERSION = '0.19.1';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+36
View File
@@ -172,6 +172,8 @@ export function FlexSetAPFLevel(arg1:number):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 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 FlexSetTXSlice(arg1:number):Promise<void>;
export function FlexSetTunePower(arg1:number):Promise<void>;
export function FlexSetVox(arg1:boolean):Promise<void>;
@@ -350,16 +354,32 @@ export function IcomSetAGC(arg1:string):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 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 IcomSetKeySpeed(arg1:number):Promise<void>;
export function IcomSetManualNotch(arg1:boolean):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 IcomSetNBLevel(arg1:number):Promise<void>;
@@ -368,8 +388,16 @@ export function IcomSetNR(arg1:boolean):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 IcomSetPower(arg1:boolean):Promise<void>;
export function IcomSetPreamp(arg1:number):Promise<void>;
export function IcomSetRFGain(arg1:number):Promise<void>;
@@ -382,10 +410,18 @@ export function IcomSetRITOn(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 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>;
+72
View File
@@ -306,6 +306,10 @@ export function FlexSetATUMemories(arg1) {
return window['go']['main']['App']['FlexSetATUMemories'](arg1);
}
export function FlexSetActiveSlice(arg1) {
return window['go']['main']['App']['FlexSetActiveSlice'](arg1);
}
export function FlexSetAudioLevel(arg1) {
return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
}
@@ -394,6 +398,10 @@ export function FlexSetTXAntenna(arg1) {
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
}
export function FlexSetTXSlice(arg1) {
return window['go']['main']['App']['FlexSetTXSlice'](arg1);
}
export function FlexSetTunePower(arg1) {
return window['go']['main']['App']['FlexSetTunePower'](arg1);
}
@@ -662,6 +670,18 @@ export function 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) {
return window['go']['main']['App']['IcomSetAtt'](arg1);
}
@@ -670,6 +690,14 @@ 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) {
return window['go']['main']['App']['IcomSetFilter'](arg1);
}
@@ -678,10 +706,22 @@ 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) {
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) {
return window['go']['main']['App']['IcomSetNB'](arg1);
}
@@ -698,10 +738,26 @@ export function 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) {
return window['go']['main']['App']['IcomSetPTT'](arg1);
}
export function IcomSetPower(arg1) {
return window['go']['main']['App']['IcomSetPower'](arg1);
}
export function IcomSetPreamp(arg1) {
return window['go']['main']['App']['IcomSetPreamp'](arg1);
}
@@ -726,6 +782,10 @@ export function 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) {
return window['go']['main']['App']['IcomSetScopeMode'](arg1);
}
@@ -734,6 +794,18 @@ export function 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);
}
+78
View File
@@ -250,6 +250,7 @@ export namespace award {
valid_from?: string;
valid_to?: string;
alias?: string;
ref_display?: string;
type?: string;
field: string;
match_by?: string;
@@ -289,6 +290,7 @@ export namespace award {
this.valid_from = source["valid_from"];
this.valid_to = source["valid_to"];
this.alias = source["alias"];
this.ref_display = source["ref_display"];
this.type = source["type"];
this.field = source["field"];
this.match_by = source["match_by"];
@@ -541,9 +543,34 @@ export namespace cat {
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 {
available: boolean;
model?: string;
slices?: FlexSliceInfo[];
rf_power: number;
tune_power: number;
tune: boolean;
@@ -600,6 +627,7 @@ export namespace cat {
if ('string' === typeof source) source = JSON.parse(source);
this.available = source["available"];
this.model = source["model"];
this.slices = this.convertValues(source["slices"], FlexSliceInfo);
this.rf_power = source["rf_power"];
this.tune_power = source["tune_power"];
this.tune = source["tune"];
@@ -690,10 +718,24 @@ export namespace cat {
nr: boolean;
nr_level: number;
anf: boolean;
apf: boolean;
agc?: string;
preamp: number;
att: 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 = {}) {
return new IcomTXState(source);
@@ -723,10 +765,24 @@ export namespace cat {
this.nr = source["nr"];
this.nr_level = source["nr_level"];
this.anf = source["anf"];
this.apf = source["apf"];
this.agc = source["agc"];
this.preamp = source["preamp"];
this.att = source["att"];
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 {
@@ -1290,6 +1346,9 @@ export namespace main {
icom_port: string;
icom_baud: number;
icom_addr: number;
icom_net_host: string;
icom_net_user: string;
icom_net_pass: string;
tci_host: string;
tci_port: number;
tci_spots: boolean;
@@ -1312,6 +1371,9 @@ export namespace main {
this.icom_port = source["icom_port"];
this.icom_baud = source["icom_baud"];
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_port = source["tci_port"];
this.tci_spots = source["tci_spots"];
@@ -2257,6 +2319,14 @@ export namespace netctl {
dxcc?: number;
itu?: number;
cq?: number;
grid?: string;
address?: string;
state?: string;
cnty?: string;
cont?: string;
lat?: number;
lon?: number;
email?: string;
groups?: string;
sig?: string;
sig_info?: string;
@@ -2274,6 +2344,14 @@ export namespace netctl {
this.dxcc = source["dxcc"];
this.itu = source["itu"];
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.sig = source["sig"];
this.sig_info = source["sig_info"];
+5
View File
@@ -55,6 +55,11 @@ type Def struct {
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)
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 AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
+66
View File
@@ -32,6 +32,16 @@ type Backend interface {
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.
//
// FreqHz follows the ADIF FREQ convention: it is the TX frequency. When the
@@ -156,6 +166,7 @@ func (m *Manager) stopLocked() {
m.mu.Lock()
stop := m.stopCh
done := m.doneCh
b := m.backend
m.stopCh = nil
m.doneCh = nil
m.cmdCh = nil
@@ -164,6 +175,11 @@ func (m *Manager) stopLocked() {
if stop != nil {
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 {
<-done
}
@@ -229,9 +245,24 @@ func (m *Manager) SendSpot(s SpotInfo) {
// FlexTXState is the FlexRadio transmit/ATU state surfaced to the dedicated
// 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.)
// 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 {
Available bool `json:"available"` // backend is Flex and handshaked
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"`
TunePower int `json:"tune_power"`
Tune bool `json:"tune"` // tune carrier active
@@ -323,6 +354,8 @@ type FlexController interface {
SetMute(bool) error
SetRXAntenna(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
SetNB(bool) error
SetNBLevel(int) error
@@ -399,10 +432,27 @@ type IcomTXState struct {
NR bool `json:"nr"`
NRLevel int `json:"nr_level"`
ANF bool `json:"anf"`
APF bool `json:"apf"` // audio peak filter (CW only)
AGC string `json:"agc,omitempty"` // FAST | MID | SLOW
Preamp int `json:"preamp"` // 0=off, 1=P.AMP1, 2=P.AMP2
Att int `json:"att"` // dB attenuation, 0=off
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
@@ -418,6 +468,7 @@ type IcomController interface {
SetNR(bool) error
SetNRLevel(int) error
SetANF(bool) error
SetAPF(bool) error
SetAGC(string) error
SetPreamp(int) error
SetAtt(int) error
@@ -428,6 +479,7 @@ type IcomController interface {
TuneATU() error
SetScope(bool) error // enable/disable the spectrum-scope waveform stream
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)
SetRIT(int) error // RIT/ΔTX offset in signed Hz
SetRITOn(bool) error // RIT on/off
@@ -436,6 +488,20 @@ type IcomController interface {
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
+19 -3
View File
@@ -37,7 +37,9 @@ const (
CmdPTT = 0x1C // sub 0x00 = PTT
CmdExtra = 0x1A // sub 0x06 = data mode on modern Icoms
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)
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
CmdMeter = 0x15 // meters (sub + 2 BCD bytes, 0000-0255): S-meter/Po/SWR
@@ -67,8 +69,16 @@ const (
// CmdLevel sub-commands.
SubLevelAF = 0x01 // AF (volume)
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
SubLevelNotch = 0x0D // manual-notch position — 0-255, 128 = centre
SubLevelComp = 0x0E // speech-compressor level
SubLevelNB = 0x12 // noise-blanker depth
SubLevelMon = 0x15 // monitor gain
SubLevelVOXGain = 0x16 // VOX gain
SubLevelAntiVOX = 0x17 // anti-VOX level
SubLevelRFPower = 0x0A // TX RF output power
SubLevelMic = 0x0B // mic gain
@@ -84,9 +94,10 @@ const (
SubScopeData = 0x00 // waveform data frame (divided across several frames)
SubScopeOnOff = 0x10 // turn the scope display itself on/off (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
SubScopeSpan = 0x15 // span in center mode — VERIFY on rig
SubScopeEdge = 0x16 // fixed-mode edge frequencies — VERIFY on rig
SubScopeMode = 0x14 // center/fixed mode (0=center, 1=fixed)
SubScopeSpan = 0x15 // span in center mode (±span/2 as 5 LE-BCD)
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.
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
@@ -94,7 +105,12 @@ const (
SubSwNB = 0x22 // noise blanker on/off
SubSwNR = 0x40 // noise reduction 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).
+201 -101
View File
@@ -4,6 +4,7 @@ package cat
import (
"bufio"
"context"
"encoding/binary"
"fmt"
"math"
@@ -27,6 +28,7 @@ type Flex struct {
mu sync.Mutex
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
seq int
handle string
@@ -171,7 +173,19 @@ func (f *Flex) Connect() error {
if host == "" {
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 {
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
// 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.
@@ -743,21 +774,19 @@ func (f *Flex) ReadState() (RigState, error) {
if !f.gotHandle {
return st, nil // connected TCP but radio hasn't handshaked yet
}
rx, tx := f.pickSlicesLocked()
if rx == nil && tx == nil {
main, rxS, txSplit := f.operatingLocked()
if main == nil {
return st, nil
}
if tx == nil {
tx = rx
}
if rx == nil {
rx = tx
}
st.FreqHz = tx.freqHz
st.Mode = flexModeToADIF(tx.mode)
if rx.freqHz != tx.freqHz {
// Main frequency/mode = the ACTIVE slice (what the operator is on). Only a
// genuine same-band split adds a separate TX freq; then ADIF convention wants
// FreqHz = TX and RxFreqHz = RX.
st.FreqHz = main.freqHz
st.Mode = flexModeToADIF(main.mode)
if rxS != nil && txSplit != nil {
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)
if sig != f.lastStateSig {
@@ -767,76 +796,130 @@ func (f *Flex) ReadState() (RigState, error) {
return st, nil
}
// pickSlicesLocked chooses the TX and RX slices among in-use slices. TX is the
// slice flagged tx=1. RX is the slice you actually receive on — the NON-TX slice
// (preferring the active/focused one), NOT simply the active slice: tuning the
// TX slice makes it the active/focused slice, which would otherwise collapse RX
// onto TX and hide the split. Caller holds f.mu.
func (f *Flex) pickSlicesLocked() (rx, tx *flexSlice) {
idxs := make([]int, 0, len(f.slices))
for i, s := range f.slices {
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
// mainSliceLocked is the operator's slice: the ACTIVE (focused) in-use slice, or
// the lowest-indexed in-use slice when none is flagged active. EVERYTHING the
// user does — freq/mode display, RX DSP, tuning, mode changes, spot clicks —
// follows this slice, so a second independent slice (e.g. monitoring another
// band) never hijacks the main frequency. Returns (-1, nil) when no slice is in
// use. Caller holds f.mu.
func (f *Flex) mainSliceLocked() (int, *flexSlice) {
best, bestS := 1<<30, (*flexSlice)(nil)
for idx, s := range f.slices {
if !s.inUse {
continue
}
if s.active {
return idx
return idx, s
}
if idx < best {
best, found = idx, true
best, bestS = idx, s
}
}
if found {
return best
if bestS != nil {
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
}
// 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 {
if hz <= 0 {
return fmt.Errorf("flex: invalid frequency")
@@ -1017,19 +1100,10 @@ func clampLevel(v int) int {
return v
}
// rxSliceLocked returns the active RX slice and its index (-1 when none), using
// the same RX-selection rule as ReadState. Caller holds f.mu.
// rxSliceLocked returns the operator's (main/active) slice and its index — the
// slice every RX-DSP control and read targets. Caller holds f.mu.
func (f *Flex) rxSliceLocked() (int, *flexSlice) {
rx, _ := f.pickSlicesLocked()
if rx == nil {
return -1, nil
}
for i, s := range f.slices {
if s == rx {
return i, rx
}
}
return -1, rx
return f.mainSliceLocked()
}
// 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,
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.RXFreqHz = rx.freqHz
st.TXFreqHz = tx.freqHz
st.RXFreqHz = rxS.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 {
st.RXAvail = true
@@ -1198,35 +1293,40 @@ func (f *Flex) SetSplit(on bool) error {
f.mu.Unlock()
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
for i, s := range f.slices {
if s == rx {
if s == rxS {
rxIdx = i
}
if s == tx {
if s == txS {
txIdx = i
}
}
alreadySplit := rx != nil && tx != nil && rx != tx
var rxFreq int64
var rxMode, rxAnt string
if rx != nil {
rxFreq, rxMode, rxAnt = rx.freqHz, rx.mode, rx.rxAnt
alreadySplit := rxS != nil && txS != nil
var baseFreq int64
var baseMode, baseAnt string
if main != nil {
baseFreq, baseMode, baseAnt = main.freqHz, main.mode, main.rxAnt
}
f.mu.Unlock()
if on {
if alreadySplit || rx == nil {
return nil // already split (or no slice)
if alreadySplit || main == nil {
return nil // already split, or no active slice
}
offset := int64(5000)
if strings.HasPrefix(strings.ToUpper(rxMode), "CW") {
if strings.HasPrefix(strings.ToUpper(baseMode), "CW") {
offset = 1000
}
cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(rxFreq+offset)/1e6, rxMode)
if rxAnt != "" {
cmd += " ant=" + rxAnt
// Create the TX slice at the ACTIVE slice's freq + offset (same band).
cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(baseFreq+offset)/1e6, baseMode)
if baseAnt != "" {
cmd += " ant=" + baseAnt
}
if seq := f.send(cmd); seq > 0 {
f.mu.Lock()
@@ -1239,10 +1339,10 @@ func (f *Flex) SetSplit(on bool) error {
return nil
}
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 {
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
}
+851
View File
@@ -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
}
+462 -7
View File
@@ -27,6 +27,26 @@ type civTransport interface {
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
@@ -75,6 +95,8 @@ type IcomSerial struct {
splitOn bool // last read split state (refreshed every few cycles)
splitTXFreq int64 // last read unselected/TX VFO freq while in split
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)
lastSetFreqAt time.Time
@@ -83,6 +105,29 @@ type IcomSerial struct {
// / setters) — hence the mutex.
dspMu sync.Mutex
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 (
@@ -130,6 +175,11 @@ func (b *IcomSerial) Connect() error {
if b.open == nil {
return fmt.Errorf("no transport configured")
}
// 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 {
return err
@@ -154,6 +204,11 @@ func (b *IcomSerial) Connect() error {
b.readerDone = make(chan struct{})
go b.reader(port, 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.
if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil {
@@ -166,7 +221,11 @@ func (b *IcomSerial) Connect() error {
// Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub
// selector byte; single-scope rigs (IC-7300…) do not.
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
}
@@ -191,11 +250,38 @@ func (b *IcomSerial) ReadState() (RigState, error) {
hz, err := b.readFreq()
if err != nil {
// The rig briefly stops answering CI-V while it switches band/VFO. Treat a
// few consecutive misses as transient — keep the connection and report the
// last known state — so a band change doesn't trigger a full disconnect +
// 5 s reconnect (which showed the new frequency ~10 s late). Only after
// several failures do we declare the rig lost so the Manager reconnects.
// Network transport: if the control link is still alive, the rig is simply
// silent — either in standby / powered OFF (the ON button is manual now), or
// mid band-change. Stay CONNECTED and show last-known state (empty until the
// rig is switched on) rather than tearing the whole UDP session down and
// 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++
if b.readFails <= 6 && b.curFreq > 0 {
s.FreqHz = b.curFreq
@@ -268,6 +354,15 @@ func (b *IcomSerial) ReadState() (RigState, error) {
b.dsp.PowerMeter = po
b.dsp.SWRMeter = swr
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
}
@@ -306,6 +401,30 @@ func (b *IcomSerial) SetPTT(on bool) error {
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 ───────────────────────────────────────────────────────────────
func (b *IcomSerial) write(payload ...byte) error {
@@ -325,8 +444,14 @@ func (b *IcomSerial) write(payload ...byte) error {
// 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,
// 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) {
b.dialMu.Lock()
cancel := b.dialCancel
b.dialMu.Unlock()
deadline := time.After(timeout)
for {
select {
@@ -334,6 +459,8 @@ func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (
if match(f) {
return f, nil
}
case <-cancel:
return civ.Decoded{}, fmt.Errorf("icom: interrupted")
case <-deadline:
return civ.Decoded{}, fmt.Errorf("icom: timeout waiting for response")
}
@@ -374,6 +501,37 @@ func (b *IcomSerial) reader(port civTransport, 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
// full it drops the oldest entry to make room for the newest.
func (b *IcomSerial) route(ch chan civ.Decoded, f civ.Decoded) {
@@ -451,6 +609,46 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
if seq == 0 || tot == 0 {
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
regions = make(map[byte][]byte)
total = tot
@@ -552,6 +750,58 @@ func (b *IcomSerial) SetScopeMode(fixed bool) error {
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 {
@@ -916,6 +1166,9 @@ func (b *IcomSerial) readDSP() {
if v, ok := b.readSwitch(civ.SubSwANF); ok {
st.ANF = v != 0
}
if v, ok := b.readSwitch(civ.SubSwAPF); ok {
st.APF = v != 0
}
if v, ok := b.readSwitch(civ.SubSwAGC); ok {
st.AGC = agcName(v)
}
@@ -935,6 +1188,46 @@ func (b *IcomSerial) readDSP() {
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.dsp = st
@@ -982,6 +1275,22 @@ func (b *IcomSerial) readAtt() (int, bool) {
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) {
if err := b.write(civ.CmdReadMode); err != nil {
return 0, 0, false
@@ -1051,6 +1360,14 @@ func (b *IcomSerial) SetANF(on bool) error {
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 {
v := agcValue(name)
if v == 0 {
@@ -1116,6 +1433,24 @@ func (b *IcomSerial) SetMicGain(p int) 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 {
return err
}
@@ -1123,6 +1458,126 @@ func (b *IcomSerial) SetIcomSplit(on bool) error {
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).
func (b *IcomSerial) TuneATU() error {
return b.exec(civ.CmdATU, civ.SubATU, 0x02)
+29 -1
View File
@@ -3,6 +3,7 @@
package cat
import (
"context"
"fmt"
"net"
"strconv"
@@ -29,6 +30,7 @@ type TCI struct {
mu sync.Mutex // guards conn + writes + state
conn *websocket.Conn
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
ready bool
// Cached state pushed by the radio.
@@ -70,8 +72,18 @@ func (t *TCI) Connect() error {
return fmt.Errorf("tci: no host configured")
}
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}
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 {
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.
func (t *TCI) ReadState() (RigState, error) {
t.mu.Lock()
+6 -5
View File
@@ -303,11 +303,12 @@ func normalizeCallsign(s string) string {
if p == "" {
continue
}
// A TRAILING /MM (maritime) or /AM (aeronautical) mobile is stripped and
// the operator's home entity is kept, so the contact still resolves to a
// country in the log (e.g. YB1SCY/AM → Indonesia). A LEADING "MM" is the
// Scotland operating prefix (MM/F4NIE) and must NOT be stripped.
if i > 0 && (p == "MM" || p == "AM") {
// A TRAILING /MM (maritime) or /AM (aeronautical) mobile, or /B (beacon), is
// stripped and the operator's home entity is kept, so the contact still
// resolves to a country in the log (e.g. YB1SCY/AM → Indonesia, 4U1UN/B →
// 4U1UN → United Nations HQ). A LEADING "MM"/"B" is a PREFIX (MM = Scotland,
// B = China: B/F4NIE) and must NOT be stripped.
if i > 0 && (p == "MM" || p == "AM" || p == "B") {
continue
}
if suffixModifiers[p] {
+2
View File
@@ -224,6 +224,8 @@ func TestNormalize(t *testing.T) {
"MM/LY3X/P": "MM",
"F4BPO/W6": "W6",
"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 {
if got := normalizeCallsign(in); got != want {
+10
View File
@@ -29,6 +29,16 @@ type Station struct {
DXCC int `json:"dxcc,omitempty"`
ITU int `json:"itu,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"`
SIG string `json:"sig,omitempty"`
SIGInfo string `json:"sig_info,omitempty"`
+28 -1
View File
@@ -46,6 +46,28 @@ func (r *Repo) List(ctx context.Context) ([]Record, error) {
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.
func (r *Repo) Get(ctx context.Context, id int64) (Record, error) {
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
// any profile-less default, then the newest template. sql.ErrNoRows if none.
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
WHERE profile_id = ? OR profile_id IS NULL
ORDER BY (is_default = 1 AND profile_id = ?) 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)
}
+1
View File
@@ -194,6 +194,7 @@ type QSOBox struct {
H float64 `json:"h"`
BG string `json:"bg"`
BGOpacity float64 `json:"bg_opacity"`
FG string `json:"fg,omitempty"` // text colour (default dark)
Radius float64 `json:"radius"`
Title string `json:"title"`
Fields []string `json:"fields"`
+20
View File
@@ -723,6 +723,26 @@ var bulkEditableCols = map[string]bool{
"my_antenna": true,
"my_sig": 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
"comment": true,
"notes": true,
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.17"
appVersion = "0.19.1"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.