9 Commits
24 changed files with 2907 additions and 319 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).*
+142 -3
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()
}
@@ -4276,7 +4289,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 +4303,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 +4394,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,
@@ -7824,6 +7843,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 +7899,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 +7997,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 +8031,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 {
@@ -8349,8 +8484,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.
+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)
}
+15 -2
View File
@@ -889,12 +889,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(() => ''),
@@ -3080,6 +3080,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">
@@ -4344,6 +4356,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'}
/>
)}
+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>
+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>
+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>
+8 -4
View File
@@ -130,7 +130,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)",
@@ -191,7 +193,7 @@ const en: Dict = {
'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.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)
@@ -320,7 +322,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)",
@@ -375,7 +379,7 @@ const fr: Dict = {
'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.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',
+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.18';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+32
View File
@@ -350,16 +350,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 +384,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 +406,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>;
+64
View File
@@ -662,6 +662,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 +682,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 +698,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 +730,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 +774,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 +786,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);
}
+34
View File
@@ -690,10 +690,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 +737,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 +1318,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 +1343,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"];
+49
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
}
@@ -399,10 +415,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 +451,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 +462,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 +471,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).
+32 -1
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.
+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 {
+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.18"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.