Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7410fa4825 | ||
|
|
246d9d1b6a | ||
|
|
d12cfe94cd | ||
|
|
ede81d3926 | ||
|
|
f27faa81a6 | ||
|
|
69e6c20b97 | ||
|
|
d93c40ebb3 | ||
|
|
dfe3afbf2d | ||
|
|
bb3542c920 | ||
|
|
a966abd9df | ||
|
|
95482ec1d8 | ||
|
|
500fe8bfec | ||
|
|
87ff0a9e16 | ||
|
|
18f9b915c5 | ||
|
|
b3fdd0b7fa | ||
|
|
9846354bf9 | ||
|
|
3da1d71323 | ||
|
|
72f692aa04 | ||
|
|
f6cd6e999a | ||
|
|
296a4a55c0 | ||
|
|
acf73c73c7 | ||
|
|
5777c119cb | ||
|
|
85bf0da006 | ||
|
|
b6ea07e3a3 | ||
|
|
549adc8c0e | ||
|
|
0e1e7d9f3c | ||
|
|
3dd428d748 | ||
|
|
c62d992ad6 | ||
|
|
a83acb0f9a |
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deniedCallHashes lists station callsigns not permitted to run this build,
|
||||||
|
// stored as SHA-256 hex of the base call so the calls themselves appear nowhere
|
||||||
|
// in the source or the compiled binary. Enforcement is best-effort by design —
|
||||||
|
// the call is entered by the operator and can be changed — so this only turns
|
||||||
|
// away straightforward use, not a determined one.
|
||||||
|
var deniedCallHashes = map[string]struct{}{
|
||||||
|
"2282a88b3e5e4eebc6b8174d005bb46d32025758b7741bdcf34f2b3621c02205": {},
|
||||||
|
"0ee09fe60817a2a4982f5e5b14a60b8dbb11cff55b3d36661213c4cbdd0933ea": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// callDenied reports whether a callsign is on deniedCallHashes. The call is
|
||||||
|
// reduced to its base form (upper-cased, portable prefix/suffix dropped) the
|
||||||
|
// same way extsvc does, so F4XYZ/P matches F4XYZ.
|
||||||
|
func callDenied(call string) bool {
|
||||||
|
base := denyBaseCall(call)
|
||||||
|
if base == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(base))
|
||||||
|
_, bad := deniedCallHashes[hex.EncodeToString(sum[:])]
|
||||||
|
return bad
|
||||||
|
}
|
||||||
|
|
||||||
|
// denyBaseCall mirrors extsvc.baseCall: for a slashed form it returns the
|
||||||
|
// longest token (the real call), otherwise the call itself, upper-cased.
|
||||||
|
func denyBaseCall(s string) string {
|
||||||
|
s = strings.ToUpper(strings.TrimSpace(s))
|
||||||
|
if !strings.Contains(s, "/") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
best := ""
|
||||||
|
for _, part := range strings.Split(s, "/") {
|
||||||
|
if len(part) > len(best) {
|
||||||
|
best = part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforceCallGate exits the process silently when any of the supplied callsigns
|
||||||
|
// is denied. Called once at startup, after the profiles are loaded: the first
|
||||||
|
// launch is where the operator enters and saves the call, so a denied build
|
||||||
|
// simply does not come back up on the next launch. No window, no message.
|
||||||
|
func enforceCallGate(calls ...string) {
|
||||||
|
for _, c := range calls {
|
||||||
|
if callDenied(c) {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+144
@@ -1,4 +1,148 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.23.5",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"CAT sharing (Hamlib rigctld): fixed a slow frequency drift in FT8. With a digital app (JTDX/WSJT-X) sharing OpsLog's rig in \"Fake It\" split, each transmit crept the dial down and never came back. The app follows the dial by polling, and our reply lagged the last tune by a poll cycle, so right after a transmission it read the still-shifted frequency, took it for a manual QSY and adopted it. Reads now echo the last commanded frequency until the rig confirms it — the dial holds. Applies to every backend, not just Kenwood."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Partage CAT (rigctld Hamlib) : dérive lente de fréquence en FT8 corrigée. Avec une app numérique (JTDX/WSJT-X) partageant la radio d'OpsLog en split « Fake It », chaque passage en émission faisait descendre le VFO sans jamais revenir. L'app suit le VFO en l'interrogeant, et notre réponse était en retard d'un cycle sur la dernière syntonisation : juste après une émission elle lisait la fréquence encore décalée, la prenait pour un QSY manuel et l'adoptait. Les lectures renvoient désormais la dernière fréquence commandée jusqu'à confirmation de la radio — le VFO tient. Vaut pour tous les backends, pas seulement Kenwood."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.23.4",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"CW keyer (Kenwood/Elecraft): choosing the Kenwood/Elecraft engine now actually switches to it. A bug left the keyer on WinKeyer even though the setting showed Kenwood/Elecraft, so its CW-over-CAT never ran.",
|
||||||
|
"Kenwood/Elecraft data mode: a new setting (Settings → CAT) chooses what a data mode (FT8/PSK…) sets on the rig — USB (default), DATA mode (MD6, for an Elecraft K3/K4), or leave the rig's mode unchanged (safest for a TS-590SG/TS-990S, whose data mode is a USB modifier set on the radio). There is no single command that fits every rig, so it's now the operator's choice.",
|
||||||
|
"Ultrabeam over a remote link: changing the pattern (Normal / 180° / bidirectional) no longer snaps back to the old one after a few seconds. The commanded pattern is now held while the motors are still moving plus a grace window after they stop, so the slow remote status poll — which lagged behind the antenna — can't revert the display while the change is in flight.",
|
||||||
|
"QSL Manager: closing the window — or starting another download — now cancels the one still in progress, so a slow QRZ sync no longer keeps running in the background and bleeds its log into a freshly started LoTW run. The QRZ download summary also spells out that only confirmed QSOs are acted on (e.g. \"7950 of 26164 are confirmed\") — a full QRZ logbook has many not-yet-confirmed QSOs, so nothing is lost even though the old wording read as if only a fraction came back. (A LoTW \"http 503\" is separate: the ARRL server being down — retry later.)"
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Keyer CW (Kenwood/Elecraft) : choisir le moteur Kenwood/Elecraft y bascule désormais réellement. Un bug laissait le keyer sur WinKeyer alors que le réglage affichait Kenwood/Elecraft, son CW-sur-CAT ne démarrait donc jamais.",
|
||||||
|
"Mode data Kenwood/Elecraft : un nouveau réglage (Réglages → CAT) choisit ce qu'un mode data (FT8/PSK…) règle sur la radio — USB (défaut), mode DATA (MD6, pour un Elecraft K3/K4), ou ne pas changer le mode de la radio (le plus sûr pour un TS-590SG/TS-990S dont le mode data est un modificateur d'USB réglé sur la radio). Aucune commande unique ne convient à toutes les radios, c'est donc désormais au choix de l'opérateur.",
|
||||||
|
"Ultrabeam en remote : changer le diagramme (Normal / 180° / bidirectionnel) ne revient plus à l'ancien au bout de quelques secondes. Le diagramme commandé est maintenu tant que les moteurs bougent, plus une fenêtre de grâce après leur arrêt — le poll de statut distant, lent et en retard sur l'antenne, ne peut donc plus faire revenir l'affichage pendant que le changement est en cours.",
|
||||||
|
"QSL Manager : fermer la fenêtre — ou lancer un autre téléchargement — annule désormais celui en cours, une synchro QRZ lente ne continue donc plus en arrière-plan à mélanger son journal à un téléchargement LoTW fraîchement lancé. Le récapitulatif QRZ indique aussi clairement que seuls les QSO confirmés sont traités (ex. « 7950 sur 26164 confirmés ») — un logbook QRZ complet contient beaucoup de QSO pas encore confirmés, rien n'est donc perdu même si l'ancien texte donnait l'impression qu'une fraction seulement était revenue. (Un « http 503 » sur LoTW est un autre sujet : serveur de l'ARRL indisponible — réessayez plus tard.)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.23.3",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Multiple rotors: Settings → Rotator is now a list, like the amplifiers — add rotors with “Add rotor”, remove with the trash icon. Mix any types (an ARCO and a Rotator Genius); a Rotator Genius set to “drives two rotors” adds a second one. The compass has a selector to pick the active rotor, which it turns, displays and points to the DX; each rotor is named, and you mark which one carries the motorized antenna (Ultrabeam/SteppIR) so the boom and reverse/bidirectional pattern paths show only for it.",
|
||||||
|
"CAT PTT hotkey: pick a key in Settings → CAT and it keys the transmitter over CAT while OpsLog is focused — hold-to-talk or toggle (a rig with no CAT link falls back to the Audio-tab RTS/DTR method). Keys that need AltGr on your layout (e.g. \"\\\" on AZERTY) work, and each press is written to the diagnostic log.",
|
||||||
|
"TCI spots (ExpertSDR / SunSDR): OpsLog's cluster spots now show on the panorama — the colour is sent as the decimal integer the protocol expects (a hex string was silently dropped), with a valid modulation — and clicking one fills the entry (callsign + frequency) via the CLICKED_ON_SPOT message.",
|
||||||
|
"FlexRadio spot click: clicking one of OpsLog's spots on the SmartSDR panadapter now switches the mode too, not just the frequency.",
|
||||||
|
"PowerGenius XL: the widget and the FlexRadio amplifier card now read forward power, drain current and temperature from the amp's own link (polled every 250 ms with peak-hold), so the meters keep working — and stay steady — over a remote link where the radio's VITA stream never reaches OpsLog. Adds a remote-access password (Settings → Amplifier) to reach the amp from outside the LAN, and fixes the fan mode (Standard/Contest/Broadcast), which was reverting to Contest.",
|
||||||
|
"CW macro <LOGQSO>: a macro ending in the token (e.g. \"BK UR <STX> TU <LOGQSO>\") now logs the QSO once the CW before it has been sent, not the instant the macro starts — it was logging on the first keyed letter.",
|
||||||
|
"Filter presets: the trash icon next to a saved preset deletes it again — clicking it was loading the preset instead of removing it.",
|
||||||
|
"Kenwood/Elecraft (K3/K4): data modes (FT8/PSK/JT) now go out on USB on every band — below 10 MHz they were landing on LSB, which a K3 shows as \"DATA REV\". The frequency refreshes promptly again (the split double-check no longer runs on every poll). And a new CW keyer engine (Settings → CW Keyer → Kenwood/Elecraft) sends CW over the CAT link with the KY command — no WinKeyer and no second COM port — for a rig whose single USB port is the CAT port. Needs on-air testing on a K3."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Plusieurs rotors : Réglages → Rotator est désormais une liste, comme les amplis — ajoutez des rotors avec « Ajouter un rotor », supprimez avec la corbeille. Mélangez les types (un ARCO et un Rotator Genius) ; un Rotator Genius réglé sur « pilote deux rotors » en ajoute un second. Le compas a un sélecteur pour choisir le rotor actif, qu'il tourne, affiche et pointe vers le DX ; chaque rotor est nommé, et vous indiquez lequel porte l'antenne motorisée (Ultrabeam/SteppIR) pour que les tracés de boom et de diagramme inverse/bidirectionnel n'apparaissent que pour lui.",
|
||||||
|
"Touche PTT CAT : choisissez une touche dans Réglages → CAT et elle passe la radio en émission via CAT quand OpsLog a le focus — maintien ou bascule (une radio sans liaison CAT retombe sur la méthode RTS/DTR de l'onglet Audio). Les touches nécessitant AltGr (ex. « \\ » en AZERTY) fonctionnent, et chaque appui est tracé dans le journal de diagnostic.",
|
||||||
|
"Spots TCI (ExpertSDR / SunSDR) : les spots cluster d'OpsLog apparaissent sur le panorama — la couleur est envoyée en entier décimal attendu par le protocole (une chaîne hexadécimale était silencieusement rejetée), avec une modulation valide — et cliquer un spot remplit la saisie (indicatif + fréquence) via le message CLICKED_ON_SPOT.",
|
||||||
|
"Clic sur un spot FlexRadio : cliquer un spot d'OpsLog sur le panadapter SmartSDR change désormais aussi le mode, plus seulement la fréquence.",
|
||||||
|
"PowerGenius XL : le widget et la carte ampli du panneau FlexRadio lisent maintenant puissance directe, courant de drain et température depuis la liaison propre de l'ampli (interrogée toutes les 250 ms avec maintien de crête) — les meters continuent donc de fonctionner, et restent stables, sur une liaison distante où le flux VITA de la radio n'atteint jamais OpsLog. Ajoute un mot de passe d'accès distant (Réglages → Amplificateur) pour joindre l'ampli hors du réseau local, et corrige le mode ventilateur (Standard/Contest/Broadcast) qui repassait sur Contest.",
|
||||||
|
"Macro CW <LOGQSO> : une macro se terminant par le token (ex. « BK UR <STX> TU <LOGQSO> ») journalise le QSO une fois le CW qui précède envoyé, et non à l'instant où la macro démarre — elle journalisait dès la première lettre manipulée.",
|
||||||
|
"Presets de filtre : la corbeille à côté d'un preset enregistré le supprime de nouveau — le clic chargeait le preset au lieu de le retirer.",
|
||||||
|
"Kenwood/Elecraft (K3/K4) : les modes data (FT8/PSK/JT) partent désormais en USB sur toutes les bandes — sous 10 MHz ils passaient en LSB, ce qu'un K3 affiche comme « DATA REV ». La fréquence se rafraîchit de nouveau rapidement (la double-vérification du split ne se fait plus à chaque cycle). Et un nouveau moteur de keyer (Réglages → Keyer CW → Kenwood/Elecraft) envoie le CW sur la liaison CAT avec la commande KY — sans WinKeyer ni second port COM — pour une radio dont l'unique port USB est le port CAT. À tester sur l'air avec un K3."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.23.2",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Lookup: the locator now keeps whichever is more precise. A 4-character square from QRZ or HamQTH no longer overwrites the full 6-character locator confirmed on a previous contact.",
|
||||||
|
"LoTW badge: when ARRL's own user list has not been rebuilt for a while, the tooltip says so with its date — a station shown as \"uploaded 7 days ago\" may simply be sitting in a frozen list.",
|
||||||
|
"FlexRadio decode spots: with two WSJT-X instances on two slices, decodes are no longer placed on the wrong panadapter, and a station heard on both is now spotted on both.",
|
||||||
|
"DX cluster: a spot glued to the login prompt is no longer dropped. Clusters that print the prompt without a newline cost you the first spot after connecting.",
|
||||||
|
"Help → Diagnostic log: read the log live, without leaving OpsLog. Filter the lines, and follow the end or scroll back freely.",
|
||||||
|
"Entity stats: the DX station's sunrise and sunset (UTC) now show on the right, next to Most Wanted. Midnight sun and polar night are named as such."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Recherche : le locator garde désormais le plus précis des deux. Un carré en 4 caractères venant de QRZ ou HamQTH n'écrase plus le locator complet en 6 caractères confirmé lors d'un contact précédent.",
|
||||||
|
"Badge LoTW : quand la liste ARRL elle-même n'a pas été régénérée depuis un moment, l'infobulle le dit avec sa date — une station annoncée « dernier envoi il y a 7 jours » peut simplement figurer dans une liste figée.",
|
||||||
|
"Spots de décodes FlexRadio : avec deux instances WSJT-X sur deux slices, les décodes ne s'affichent plus sur le mauvais panadapter, et une station entendue sur les deux est désormais spottée sur les deux.",
|
||||||
|
"Cluster DX : un spot collé à l'invite de connexion n'est plus perdu. Les clusters qui affichent leur invite sans retour à la ligne coûtaient le premier spot après la connexion.",
|
||||||
|
"Aide → Journal de diagnostic : lire le journal en direct sans quitter OpsLog. Filtrage des lignes, et suivi de la fin qu'on peut relâcher pour remonter.",
|
||||||
|
"Statistiques d'entité : le lever et le coucher du soleil de la station DX (UTC) s'affichent à droite, à côté du Most Wanted. Soleil de minuit et nuit polaire sont nommés."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.23.1",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"DX cluster: NEW COUNTY is green, like NEW POTA.",
|
||||||
|
"CW: pressing ESC during a macro no longer turns the report from 599 into 59 when \"ESC clears the callsign too\" is on."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Cluster DX : NOUVEAU COMTÉ passe en vert, comme NOUVEAU POTA.",
|
||||||
|
"CW : appuyer sur Échap pendant une macro ne fait plus passer le report de 599 à 59 quand l'option « Échap efface aussi l'indicatif » est cochée."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.23.0",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Station Control: Dingtian IOT relay boards (2 to 32 relays, LAN or WiFi) can now be added.",
|
||||||
|
"ACOM amplifiers: the card now shows TUNE while the tuner is running, instead of an unknown state.",
|
||||||
|
"DX cluster: the status pills are gone. The call, band and mode turn yellow when that one is new, and a callsign already in the log turns blue.",
|
||||||
|
"Reports: starting OpsLog with the rig already in CW no longer leaves the RST fields on 59 — they now follow the mode you are actually on. (Thanks HB9HBY)",
|
||||||
|
"DX cluster: NEW PFX marks a spot whose CQ WPX prefix has never been worked, in the status column and as a filter."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Contrôle station : les cartes relais Dingtian IOT (2 à 32 relais, LAN ou WiFi) peuvent désormais être ajoutées.",
|
||||||
|
"Amplificateurs ACOM : la carte affiche TUNE pendant l'accord, au lieu d'un état inconnu.",
|
||||||
|
"Cluster DX : les pastilles de statut disparaissent. L'indicatif, la bande et le mode passent en jaune quand celui-ci est nouveau, et un indicatif déjà dans le log passe en bleu.",
|
||||||
|
"Reports : lancer OpsLog avec le poste déjà en CW ne laisse plus les RST à 59 — ils suivent maintenant le mode réellement en cours. (Merci HB9HBY)",
|
||||||
|
"Cluster DX : NOUVEAU PFX signale un spot dont le préfixe CQ WPX n'a jamais été contacté, dans la colonne de statut et en filtre."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.22.9",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Awards: the CW / Phone / Digital filter now applies to the whole award — bands, counts, confirmations and the contacts behind a cell — instead of only hiding rows from the reference list.",
|
||||||
|
"Yaesu and Kenwood: a new option lowers the DTR and RTS lines on connect, for interfaces that read either as PTT and keep the radio transmitting while OpsLog is open. Off by default — lowering them stops other interfaces transmitting at all.",
|
||||||
|
"SPE amplifiers: the ON and OFF buttons now switch the amplifier on and off over USB.",
|
||||||
|
"Quieter diagnostic log: no more FlexRadio meter subscriptions, per-packet WSJT-X/MSHV lines, or repeated Tuner Genius / PGXL / SPE status frames.",
|
||||||
|
"Station Control: the cards now line up on a grid — same width, and same height across a row — and the column selector goes up to 6.",
|
||||||
|
"New amplifier widget beside the keyers (flame icon): Operate, power ON/OFF, the SPE power level and live watts / SWR / temperature. With several amplifiers a dropdown on the icon picks the one to watch, or all of them.",
|
||||||
|
"Icom: the meters and front-panel readings are polled only while the Icom console is on screen. They were half of the CI-V traffic at all times, which matters when the link is shared — a microHAM Router splitting one CI-V port between OpsLog and WSJT-X, for instance.",
|
||||||
|
"Icom: the power-ON button sends a wake preamble sized for the CI-V speed. It was a fixed length that only suited 19200 baud, so a radio set faster — 115200 is the default — ignored the command.",
|
||||||
|
"The Tuner Genius power meter now shows the peak the device itself measured, instead of whatever instant the poll happened to catch — a kilowatt transmission could read a few hundred watts. Every power meter also clears the moment the carrier drops, instead of trailing several seconds behind it."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Diplômes : le filtre CW / Phonie / Numérique s’applique désormais à tout le diplôme — bandes, comptes, confirmations et QSO derrière une case — au lieu de masquer seulement des lignes de la liste des références.",
|
||||||
|
"Yaesu et Kenwood : une option abaisse les lignes DTR et RTS à la connexion, pour les interfaces qui les lisent comme PTT et laissent la radio en émission tant qu’OpsLog est ouvert. Désactivée par défaut — les abaisser empêche d’autres interfaces d’émettre.",
|
||||||
|
"Amplificateurs SPE : les boutons ON et OFF allument et éteignent désormais l’amplificateur par l’USB.",
|
||||||
|
"Journal de diagnostic allégé : plus d’abonnements aux mètres FlexRadio, de ligne par paquet WSJT-X/MSHV, ni de trames statut Tuner Genius / PGXL / SPE répétées.",
|
||||||
|
"Contrôle station : les cartes s’alignent désormais sur une grille — même largeur, et même hauteur sur une ligne — et le sélecteur de colonnes va jusqu’à 6.",
|
||||||
|
"Nouveau widget amplificateur à côté des manipulateurs (icône flamme) : Operate, marche/arrêt, le niveau de puissance SPE et les watts / ROS / température en direct. Avec plusieurs amplis, une liste déroulante sur l’icône choisit celui à surveiller, ou tous.",
|
||||||
|
"Icom : les mesures et les réglages de façade ne sont interrogés que lorsque la console Icom est affichée. Ils représentaient la moitié du trafic CI-V en permanence, ce qui compte quand la liaison est partagée — un Router microHAM qui répartit un port CI-V entre OpsLog et WSJT-X, par exemple.",
|
||||||
|
"Icom : le bouton de mise sous tension envoie un préambule de réveil dimensionné pour la vitesse CI-V. Sa longueur était fixe et ne convenait qu’à 19200 bauds, si bien qu’une radio réglée plus vite — 115200 par défaut — ignorait la commande.",
|
||||||
|
"Le mètre de puissance du Tuner Genius affiche désormais la crête mesurée par l’appareil lui-même, au lieu de l’instant capté par l’interrogation — une émission d’un kilowatt pouvait afficher quelques centaines de watts. Tous les mètres de puissance retombent aussi dès la fin de l’émission, au lieu de traîner plusieurs secondes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.22.8",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Main tab: drag the divider between the two panes to resize them. The setting is remembered and travels with the data folder; double-click the divider to even them out.",
|
||||||
|
"Kenwood: bytes arriving glued to the previous answer are no longer discarded, and the link starts from a clean buffer — a rig whose replies ran together could end up one answer behind and never report connected.",
|
||||||
|
"The CAT log line now carries the error explaining a disconnection.",
|
||||||
|
"Kenwood and Yaesu CAT connect again: 0.22.7 lowered the DTR and RTS lines on those ports, which stops many USB-serial interfaces from transmitting. Only Xiegu, where the problem was reported, still does it.",
|
||||||
|
"Kenwood: a port that opens but sends nothing is now reported differently from one sending data that does not answer, and the error quotes what arrived."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Onglet Principal : faites glisser le séparateur entre les deux volets pour les redimensionner. Le réglage est mémorisé et suit le dossier de données ; double-clic pour les égaliser.",
|
||||||
|
"Kenwood : les octets reçus collés à la réponse précédente ne sont plus jetés, et la liaison démarre sur un tampon vide — une radio dont les réponses s’enchaînent pouvait rester une réponse en retard et ne jamais apparaître connectée.",
|
||||||
|
"La ligne de journal CAT indique désormais l’erreur expliquant une déconnexion.",
|
||||||
|
"Le CAT Kenwood et Yaesu se connecte de nouveau : la 0.22.7 abaissait les lignes DTR et RTS sur ces ports, ce qui empêche beaucoup d’interfaces USB-série d’émettre. Seul Xiegu, où le problème avait été signalé, le fait encore.",
|
||||||
|
"Kenwood : un port qui s’ouvre sans rien émettre est désormais distingué d’un port qui émet sans répondre, et l’erreur cite ce qui est arrivé."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.22.7",
|
"version": "0.22.7",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
+314
-64
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock,
|
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
|
||||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
|
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
|
||||||
GetSecretStatus, UnlockSecrets,
|
GetSecretStatus, UnlockSecrets,
|
||||||
RefreshCtyDat, DownloadAllReferenceLists,
|
RefreshCtyDat, DownloadAllReferenceLists,
|
||||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
RotatorGoTo, RotatorStop, GetRotatorHeading, SetActiveRotor,
|
||||||
GetDBConnectionInfo, GetLogbookRevision,
|
GetDBConnectionInfo, GetLogbookRevision,
|
||||||
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
|
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
|
||||||
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
|
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
|
||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
OpenExternalURL,
|
OpenExternalURL,
|
||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||||
GetCATSettings,
|
GetCATSettings, PTTHotkeyDown, PTTHotkeyUp,
|
||||||
GetSolarData,
|
GetSolarData,
|
||||||
GetQSORate,
|
GetQSORate,
|
||||||
LoTWUserInfo,
|
LoTWUserInfo,
|
||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
||||||
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
||||||
IcomSendCW, YaesuSendCW, YaesuStopCW, SetYaesuKeySpeed, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
IcomSendCW, YaesuSendCW, YaesuStopCW, SetYaesuKeySpeed, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
||||||
|
KenwoodSendCW, KenwoodStopCW, SetKenwoodKeySpeed,
|
||||||
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
|
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
|
||||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||||
@@ -77,6 +78,7 @@ import { IcomPanel } from '@/components/IcomPanel';
|
|||||||
import { YaesuPanel } from '@/components/YaesuPanel';
|
import { YaesuPanel } from '@/components/YaesuPanel';
|
||||||
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
||||||
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
|
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
|
||||||
|
import { AmpWidget } from '@/components/AmpWidget';
|
||||||
import { ScpPanel, type ScpResult } from '@/components/ScpPanel';
|
import { ScpPanel, type ScpResult } from '@/components/ScpPanel';
|
||||||
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
||||||
import { AwardsPanel } from '@/components/AwardsPanel';
|
import { AwardsPanel } from '@/components/AwardsPanel';
|
||||||
@@ -119,6 +121,7 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { pathBetween, pathBetweenLatLon, gridToLatLon, latLonToGrid } from '@/lib/maidenhead';
|
import { pathBetween, pathBetweenLatLon, gridToLatLon, latLonToGrid } from '@/lib/maidenhead';
|
||||||
import { flagURL } from '@/lib/flags';
|
import { flagURL } from '@/lib/flags';
|
||||||
|
import { LogViewer } from '@/components/LogViewer';
|
||||||
|
|
||||||
type QSO = QSOForm;
|
type QSO = QSOForm;
|
||||||
type ImportResult = adifModels.ImportResult;
|
type ImportResult = adifModels.ImportResult;
|
||||||
@@ -491,6 +494,14 @@ export default function App() {
|
|||||||
const [bandRx, setBandRx] = useState('20m');
|
const [bandRx, setBandRx] = useState('20m');
|
||||||
const [countries, setCountries] = useState<string[]>([]);
|
const [countries, setCountries] = useState<string[]>([]);
|
||||||
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
|
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
|
||||||
|
// Refs for the report defaults. applyModePreset is called from long-lived
|
||||||
|
// closures — the global key handler is the one that bit us — whose dependency
|
||||||
|
// lists deliberately exclude the mode, so reading the state variables there
|
||||||
|
// gives whatever they were when that closure was built.
|
||||||
|
const modePresetsRef = useRef(modePresets);
|
||||||
|
useEffect(() => { modePresetsRef.current = modePresets; }, [modePresets]);
|
||||||
|
const rstListsRef = useRef(rstLists);
|
||||||
|
useEffect(() => { rstListsRef.current = rstLists; }, [rstLists]);
|
||||||
const [rstSent, setRstSent] = useState('59');
|
const [rstSent, setRstSent] = useState('59');
|
||||||
const [rstRcvd, setRstRcvd] = useState('59');
|
const [rstRcvd, setRstRcvd] = useState('59');
|
||||||
const [grid, setGrid] = useState('');
|
const [grid, setGrid] = useState('');
|
||||||
@@ -538,7 +549,7 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
// LoTW-user lookup for the entered callsign (from the cached ARRL activity list),
|
// LoTW-user lookup for the entered callsign (from the cached ARRL activity list),
|
||||||
// debounced. Drives a colour-coded LoTW badge by last-upload recency.
|
// debounced. Drives a colour-coded LoTW badge by last-upload recency.
|
||||||
const [lotwInfo, setLotwInfo] = useState<{ is_user: boolean; last_upload?: string; days_ago: number } | null>(null);
|
const [lotwInfo, setLotwInfo] = useState<{ is_user: boolean; last_upload?: string; days_ago: number; feed_date?: string; feed_stale?: boolean } | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const c = callsign.trim();
|
const c = callsign.trim();
|
||||||
if (!c) { setLotwInfo(null); return; }
|
if (!c) { setLotwInfo(null); return; }
|
||||||
@@ -570,6 +581,16 @@ export default function App() {
|
|||||||
// PGXL's OPERATE state comes from the radio when it reports the amp.
|
// PGXL's OPERATE state comes from the radio when it reports the amp.
|
||||||
const [ampSts, setAmpSts] = useState<any[]>([]);
|
const [ampSts, setAmpSts] = useState<any[]>([]);
|
||||||
const [flexAmp, setFlexAmp] = useState<any>(null);
|
const [flexAmp, setFlexAmp] = useState<any>(null);
|
||||||
|
// Amplifier widget: whether it is shown, and which amp — "all", or one amp id.
|
||||||
|
// Several amps side by side make a wide widget, so an operator running two
|
||||||
|
// picks the one he watches while transmitting. Declared here because the poll
|
||||||
|
// below runs faster while the widget is open.
|
||||||
|
const [showAmpWidget, setShowAmpWidget] = useState(() => localStorage.getItem('opslog.showAmpWidget') !== '0');
|
||||||
|
const [ampWidgetSel, setAmpWidgetSel] = useState(() => localStorage.getItem('opslog.ampSel.widget') || 'all');
|
||||||
|
// Poll fast only while the amplifier widget is open: its meters must track TX
|
||||||
|
// the way the FlexRadio panel's do (400 ms), or the power reads seconds behind
|
||||||
|
// the transmission and looks like the app is struggling. The status-bar chips
|
||||||
|
// alone need nothing like that rate.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const tick = () => Promise.all([
|
const tick = () => Promise.all([
|
||||||
@@ -577,9 +598,9 @@ export default function App() {
|
|||||||
GetFlexState().catch(() => null),
|
GetFlexState().catch(() => null),
|
||||||
]).then(([l, fx]: any[]) => { if (alive) { setAmpSts((l ?? []) as any[]); setFlexAmp(fx); } });
|
]).then(([l, fx]: any[]) => { if (alive) { setAmpSts((l ?? []) as any[]); setFlexAmp(fx); } });
|
||||||
tick();
|
tick();
|
||||||
const id = window.setInterval(tick, 2000);
|
const id = window.setInterval(tick, showAmpWidget ? 400 : 2000);
|
||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, [showAmpWidget]);
|
||||||
// Multi-op "who's on air" widget: every operator's live status from the shared
|
// Multi-op "who's on air" widget: every operator's live status from the shared
|
||||||
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
|
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
|
||||||
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
|
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
|
||||||
@@ -608,6 +629,11 @@ export default function App() {
|
|||||||
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
||||||
// in Preferences > Hardware > CAT interface.
|
// in Preferences > Hardware > CAT interface.
|
||||||
const digitalDefaultRef = useRef<string>('FT8');
|
const digitalDefaultRef = useRef<string>('FT8');
|
||||||
|
// PTT hotkey config, refreshed by loadCATCfg. Held in a ref so the global
|
||||||
|
// key listener (registered once) always sees the current binding.
|
||||||
|
const pttHotkeyRef = useRef<{ enabled: boolean; code: string; toggle: boolean }>({ enabled: false, code: '', toggle: false });
|
||||||
|
const pttHeldRef = useRef(false); // hold-mode: PTT currently keyed by the hotkey
|
||||||
|
const pttToggledRef = useRef(false); // toggle-mode: latched TX state
|
||||||
// Don't override freq/band/mode the user JUST typed — track a small grace
|
// Don't override freq/band/mode the user JUST typed — track a small grace
|
||||||
// window after manual edits and skip CAT updates during it.
|
// window after manual edits and skip CAT updates during it.
|
||||||
const catFreezeUntilRef = useRef<number>(0);
|
const catFreezeUntilRef = useRef<number>(0);
|
||||||
@@ -871,6 +897,36 @@ export default function App() {
|
|||||||
return ALWAYS_TABS.includes(saved) ? saved : 'recent';
|
return ALWAYS_TABS.includes(saved) ? saved : 'recent';
|
||||||
});
|
});
|
||||||
useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]);
|
useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]);
|
||||||
|
|
||||||
|
// Main tab: how much width the LEFT pane gets, in percent. Clamped to 15..85
|
||||||
|
// so a pane can be made small but never dragged out of existence — recovering
|
||||||
|
// from that means finding a divider that is no longer on screen.
|
||||||
|
const [mainSplit, setMainSplit] = useState<number>(() => {
|
||||||
|
const n = parseFloat(localStorage.getItem('opslog.mainSplit') || '');
|
||||||
|
return Number.isFinite(n) && n >= 15 && n <= 85 ? n : 50;
|
||||||
|
});
|
||||||
|
useEffect(() => { writeUiPref('opslog.mainSplit', String(Math.round(mainSplit))); }, [mainSplit]);
|
||||||
|
const mainSplitRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const startMainSplitDrag = (e: React.PointerEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const host = mainSplitRef.current;
|
||||||
|
if (!host) return;
|
||||||
|
// Pointer capture on the divider, so dragging over a map keeps working —
|
||||||
|
// Leaflet would otherwise swallow the moves the moment the cursor entered it.
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
const onMove = (ev: PointerEvent) => {
|
||||||
|
const r = host.getBoundingClientRect();
|
||||||
|
if (r.width <= 0) return;
|
||||||
|
const pct = ((ev.clientX - r.left) / r.width) * 100;
|
||||||
|
setMainSplit(Math.min(85, Math.max(15, pct)));
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
window.removeEventListener('pointerup', onUp);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', onMove);
|
||||||
|
window.addEventListener('pointerup', onUp);
|
||||||
|
};
|
||||||
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
|
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
|
||||||
const [qslTabOpen, setQslTabOpen] = useState(false);
|
const [qslTabOpen, setQslTabOpen] = useState(false);
|
||||||
const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
|
const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
|
||||||
@@ -1025,7 +1081,7 @@ export default function App() {
|
|||||||
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
||||||
// auto-call and <LOGQSO> are shared; only the transport differs.
|
// auto-call and <LOGQSO> are shared; only the transport differs.
|
||||||
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
|
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
|
||||||
const cwSource: 'winkeyer' | 'icom' | 'flex' | 'yaesu' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : wkEngine === 'yaesu' ? 'yaesu' : 'winkeyer';
|
const cwSource: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : wkEngine === 'yaesu' ? 'yaesu' : wkEngine === 'kenwood' ? 'kenwood' : 'winkeyer';
|
||||||
// Setting the CW speed has to reach the keyer that is ACTUALLY sending, and
|
// Setting the CW speed has to reach the keyer that is ACTUALLY sending, and
|
||||||
// both the CW panel and the Yaesu console can ask for it. With DTR/RTS line
|
// both the CW panel and the Yaesu console can ask for it. With DTR/RTS line
|
||||||
// keying the PC does the timing, so the rig's internal keyer speed changes
|
// keying the PC does the timing, so the rig's internal keyer speed changes
|
||||||
@@ -1038,6 +1094,7 @@ export default function App() {
|
|||||||
if (src === 'icom') IcomSetKeySpeed(w).catch(() => {});
|
if (src === 'icom') IcomSetKeySpeed(w).catch(() => {});
|
||||||
else if (src === 'flex') FlexSetKeySpeed(w).catch(() => {});
|
else if (src === 'flex') FlexSetKeySpeed(w).catch(() => {});
|
||||||
else if (src === 'yaesu') SetYaesuKeySpeed(w).catch(() => {});
|
else if (src === 'yaesu') SetYaesuKeySpeed(w).catch(() => {});
|
||||||
|
else if (src === 'kenwood') SetKenwoodKeySpeed(w).catch(() => {});
|
||||||
else WinkeyerSetSpeed(w).catch(() => {});
|
else WinkeyerSetSpeed(w).catch(() => {});
|
||||||
// The rig's own keyer follows too whenever a Yaesu is on CAT, even when it is
|
// The rig's own keyer follows too whenever a Yaesu is on CAT, even when it is
|
||||||
// not the sending engine: its front panel and OpsLog then agree.
|
// not the sending engine: its front panel and OpsLog then agree.
|
||||||
@@ -1081,6 +1138,7 @@ export default function App() {
|
|||||||
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
|
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
|
||||||
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
|
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
|
||||||
: cwSource === 'yaesu' ? (catState.backend === 'yaesu' && catState.connected)
|
: cwSource === 'yaesu' ? (catState.backend === 'yaesu' && catState.connected)
|
||||||
|
: cwSource === 'kenwood' ? (catState.backend === 'kenwood' && catState.connected)
|
||||||
: wkStatus.connected;
|
: wkStatus.connected;
|
||||||
wkActiveRef.current = wkEnabled && connected;
|
wkActiveRef.current = wkEnabled && connected;
|
||||||
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
|
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
|
||||||
@@ -1341,7 +1399,7 @@ export default function App() {
|
|||||||
// county or a new park is not a DXCC state, it is another dimension of the
|
// county or a new park is not a DXCC state, it is another dimension of the
|
||||||
// same spot — which is why the grid shows them as separate badges. Filtering
|
// same spot — which is why the grid shows them as separate badges. Filtering
|
||||||
// is an OR across all of them, as it already was for a worked callsign.
|
// is an OR across all of them, as it already was for a worked callsign.
|
||||||
type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county';
|
type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county' | 'new-pfx';
|
||||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
|
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
|
||||||
// Mode filter chips. Empty set = show every mode. Categories map the
|
// Mode filter chips. Empty set = show every mode. Categories map the
|
||||||
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
||||||
@@ -1437,7 +1495,7 @@ export default function App() {
|
|||||||
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
||||||
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
|
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
|
||||||
// different slots don't share the same colour.
|
// different slots don't share the same colour.
|
||||||
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean }>>({});
|
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean; new_pfx?: boolean; pfx?: string }>>({});
|
||||||
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
|
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
|
||||||
// still need resolving without re-subscribing the cluster:spot listener.
|
// still need resolving without re-subscribing the cluster:spot listener.
|
||||||
const spotStatusRef = useRef(spotStatus);
|
const spotStatusRef = useRef(spotStatus);
|
||||||
@@ -1498,6 +1556,7 @@ export default function App() {
|
|||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
// "What's new": the changelog for the version(s) since the operator last ran,
|
// "What's new": the changelog for the version(s) since the operator last ran,
|
||||||
// shown once on the first launch after an update (EN/FR per the UI language).
|
// shown once on the first launch after an update (EN/FR per the UI language).
|
||||||
|
const [showLogViewer, setShowLogViewer] = useState(false);
|
||||||
const [whatsNew, setWhatsNew] = useState<Array<{ version: string; date: string; en: string[]; fr: string[] }> | null>(null);
|
const [whatsNew, setWhatsNew] = useState<Array<{ version: string; date: string; en: string[]; fr: string[] }> | null>(null);
|
||||||
// Which language the What's-new modal shows — defaults to the UI language but
|
// Which language the What's-new modal shows — defaults to the UI language but
|
||||||
// toggleable, so a French operator running the English UI can still read it.
|
// toggleable, so a French operator running the English UI can still read it.
|
||||||
@@ -1713,23 +1772,28 @@ export default function App() {
|
|||||||
: null
|
: null
|
||||||
), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]);
|
), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]);
|
||||||
|
|
||||||
|
// Only the active rotor carrying the motorized antenna (Ultrabeam/SteppIR)
|
||||||
|
// shows the pattern paths; a plain rotor shows just its heading. The backend
|
||||||
|
// reports Motorized for the active rotor (true for a lone/standard rotor).
|
||||||
|
const activeRotorMotorized = (rotatorHeading as any).motorized !== false;
|
||||||
|
|
||||||
const beamHeadings = useMemo<number[]>(() => {
|
const beamHeadings = useMemo<number[]>(() => {
|
||||||
if (rotorAz == null) return [];
|
if (rotorAz == null) return [];
|
||||||
if (ubStatus.enabled && ubStatus.connected) {
|
if (activeRotorMotorized && ubStatus.enabled && ubStatus.connected) {
|
||||||
if (ubStatus.direction === 1) return [(rotorAz + 180) % 360];
|
if (ubStatus.direction === 1) return [(rotorAz + 180) % 360];
|
||||||
if (ubStatus.direction === 2) return [rotorAz, (rotorAz + 180) % 360];
|
if (ubStatus.direction === 2) return [rotorAz, (rotorAz + 180) % 360];
|
||||||
}
|
}
|
||||||
return [rotorAz];
|
return [rotorAz];
|
||||||
}, [rotorAz, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
}, [rotorAz, activeRotorMotorized, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
||||||
|
|
||||||
// Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can
|
// Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can
|
||||||
// show where the antenna physically points (boom) vs where it radiates when
|
// show where the antenna physically points (boom) vs where it radiates when
|
||||||
// the Ultrabeam is reversed/bidirectional.
|
// the Ultrabeam is reversed/bidirectional.
|
||||||
const boomHeading = rotorAz;
|
const boomHeading = rotorAz;
|
||||||
const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => {
|
const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => {
|
||||||
if (!(ubStatus.enabled && ubStatus.connected)) return null;
|
if (!activeRotorMotorized || !(ubStatus.enabled && ubStatus.connected)) return null;
|
||||||
return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal';
|
return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal';
|
||||||
}, [ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
}, [activeRotorMotorized, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
||||||
|
|
||||||
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
|
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
|
||||||
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
||||||
@@ -1876,6 +1940,56 @@ export default function App() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [showToast]);
|
}, [showToast]);
|
||||||
|
|
||||||
|
// PTT hotkey: a keyboard key keys the rig while OpsLog is focused. Registered
|
||||||
|
// once; reads the live binding from pttHotkeyRef (kept current by loadCATCfg).
|
||||||
|
// Hold-to-talk by default; toggle mode latches. We swallow the key (so the
|
||||||
|
// dedicated PTT key doesn't type a character) and ignore auto-repeat.
|
||||||
|
useEffect(() => {
|
||||||
|
const down = (e: KeyboardEvent) => {
|
||||||
|
const cfg = pttHotkeyRef.current;
|
||||||
|
if (!cfg.enabled || !cfg.code || e.code !== cfg.code) return;
|
||||||
|
// Allow AltGr (Ctrl+Alt together) — French/other layouts need it to reach
|
||||||
|
// keys like "\" — but never hijack a real Ctrl- or Win-shortcut.
|
||||||
|
if (e.metaKey || (e.ctrlKey && !e.altKey)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.repeat) return;
|
||||||
|
// Surface the result: this feature was impossible to diagnose because every
|
||||||
|
// error was swallowed. The line names the key, the action and any failure.
|
||||||
|
const fail = (err: any) => { UILog(`ptt hotkey: ${e.code} FAILED: ${err}`).catch(() => {}); };
|
||||||
|
if (cfg.toggle) {
|
||||||
|
pttToggledRef.current = !pttToggledRef.current;
|
||||||
|
const on = pttToggledRef.current;
|
||||||
|
UILog(`ptt hotkey: ${e.code} toggle → ${on ? 'KEY' : 'UNKEY'}`).catch(() => {});
|
||||||
|
(on ? PTTHotkeyDown() : PTTHotkeyUp())?.catch?.(fail);
|
||||||
|
} else if (!pttHeldRef.current) {
|
||||||
|
pttHeldRef.current = true;
|
||||||
|
UILog(`ptt hotkey: ${e.code} down → KEY`).catch(() => {});
|
||||||
|
PTTHotkeyDown().catch(fail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const up = (e: KeyboardEvent) => {
|
||||||
|
const cfg = pttHotkeyRef.current;
|
||||||
|
if (!cfg.enabled || !cfg.code || e.code !== cfg.code) return;
|
||||||
|
if (cfg.toggle) return; // toggle unkeys on the next press, not on release
|
||||||
|
if (pttHeldRef.current) {
|
||||||
|
pttHeldRef.current = false;
|
||||||
|
PTTHotkeyUp();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Losing focus mid-hold would never deliver keyup — release so TX can't stick.
|
||||||
|
const blur = () => {
|
||||||
|
if (pttHeldRef.current) { pttHeldRef.current = false; PTTHotkeyUp(); }
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', down, true);
|
||||||
|
window.addEventListener('keyup', up, true);
|
||||||
|
window.addEventListener('blur', blur);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', down, true);
|
||||||
|
window.removeEventListener('keyup', up, true);
|
||||||
|
window.removeEventListener('blur', blur);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
||||||
// rotator is disabled (the backend just reads settings and returns).
|
// rotator is disabled (the backend just reads settings and returns).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2059,6 +2173,11 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const c = await GetCATSettings();
|
const c = await GetCATSettings();
|
||||||
if (c.digital_default) digitalDefaultRef.current = c.digital_default;
|
if (c.digital_default) digitalDefaultRef.current = c.digital_default;
|
||||||
|
pttHotkeyRef.current = {
|
||||||
|
enabled: !!(c as any).ptt_hotkey_enabled,
|
||||||
|
code: (c as any).ptt_hotkey ?? '',
|
||||||
|
toggle: !!(c as any).ptt_hotkey_toggle,
|
||||||
|
};
|
||||||
setCatBackend(c.backend ?? '');
|
setCatBackend(c.backend ?? '');
|
||||||
} catch {}
|
} catch {}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -2071,11 +2190,22 @@ export default function App() {
|
|||||||
setModePresets(l.modes);
|
setModePresets(l.modes);
|
||||||
const names = l.modes.map((m) => m.name);
|
const names = l.modes.map((m) => m.name);
|
||||||
setModes(names);
|
setModes(names);
|
||||||
setMode((cur) => names.includes(cur) ? cur : names[0]);
|
// Resolve the mode we are ACTUALLY on, then take that mode's report.
|
||||||
const preset = l.modes.find((m) => m.name === mode) ?? l.modes[0];
|
//
|
||||||
if (preset && !rstUserEditedRef.current) {
|
// This used to read the `mode` state variable, which this callback
|
||||||
if (preset.default_rst_sent) setRstSent(preset.default_rst_sent);
|
// captures once (its dep list is empty) — so it was still the initial
|
||||||
if (preset.default_rst_rcvd) setRstRcvd(preset.default_rst_rcvd);
|
// 'SSB' — and fell back to l.modes[0], SSB again, when the lookup
|
||||||
|
// missed. An operator who launched OpsLog with the rig already in CW
|
||||||
|
// and did not touch the VFO got 59/59 and kept it: nothing else runs
|
||||||
|
// until the CAT state actually changes.
|
||||||
|
const cur = names.includes(modeRef.current) ? modeRef.current : names[0];
|
||||||
|
setMode(cur);
|
||||||
|
if (!rstUserEditedRef.current) {
|
||||||
|
const lists: RSTLists = { phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] };
|
||||||
|
const p = l.modes.find((m) => m.name === cur);
|
||||||
|
const fallback = rstOptions(cur, lists)[0] || '';
|
||||||
|
setRstSent(p?.default_rst_sent || fallback);
|
||||||
|
setRstRcvd(p?.default_rst_rcvd || fallback);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -2135,8 +2265,9 @@ export default function App() {
|
|||||||
// Prefer the user's configured preset RST; otherwise fall back to the mode
|
// Prefer the user's configured preset RST; otherwise fall back to the mode
|
||||||
// category default (CW/RTTY/PSK → 599, phone → 59, digital → first option)
|
// category default (CW/RTTY/PSK → 599, phone → 59, digital → first option)
|
||||||
// so switching SSB→CW flips 59→599 even without a configured preset.
|
// so switching SSB→CW flips 59→599 even without a configured preset.
|
||||||
const p = modePresets.find((x) => x.name === m);
|
// Read through the refs, never the state: see their declaration.
|
||||||
const fallback = rstOptions(m, rstLists)[0] || '';
|
const p = modePresetsRef.current.find((x) => x.name === m);
|
||||||
|
const fallback = rstOptions(m, rstListsRef.current)[0] || '';
|
||||||
setRstSent(p?.default_rst_sent || fallback);
|
setRstSent(p?.default_rst_sent || fallback);
|
||||||
setRstRcvd(p?.default_rst_rcvd || fallback);
|
setRstRcvd(p?.default_rst_rcvd || fallback);
|
||||||
}
|
}
|
||||||
@@ -2409,6 +2540,8 @@ export default function App() {
|
|||||||
worked_call: !!(r as any).worked_call,
|
worked_call: !!(r as any).worked_call,
|
||||||
new_county: !!(r as any).new_county,
|
new_county: !!(r as any).new_county,
|
||||||
new_pota: !!(r as any).new_pota,
|
new_pota: !!(r as any).new_pota,
|
||||||
|
new_pfx: !!(r as any).new_pfx,
|
||||||
|
pfx: (r as any).pfx,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
@@ -2506,6 +2639,11 @@ export default function App() {
|
|||||||
const call = String(p?.call ?? '');
|
const call = String(p?.call ?? '');
|
||||||
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
|
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
|
||||||
});
|
});
|
||||||
|
// Clicking a spot on the ExpertSDR (TCI) panorama fills the call, like Flex.
|
||||||
|
const unsubTciSpot = EventsOn('tci:spot_clicked', (p: any) => {
|
||||||
|
const call = String(p?.call ?? '');
|
||||||
|
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
|
||||||
|
});
|
||||||
const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => {
|
const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => {
|
||||||
const total = Number(p?.total ?? 0);
|
const total = Number(p?.total ?? 0);
|
||||||
const processed = Number(p?.processed ?? 0);
|
const processed = Number(p?.processed ?? 0);
|
||||||
@@ -2540,7 +2678,7 @@ export default function App() {
|
|||||||
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
||||||
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
|
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
|
||||||
});
|
});
|
||||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
|
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubTciSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -2568,8 +2706,9 @@ export default function App() {
|
|||||||
s.engine === 'icom' ? 'icom'
|
s.engine === 'icom' ? 'icom'
|
||||||
: s.engine === 'flex' ? 'flex'
|
: s.engine === 'flex' ? 'flex'
|
||||||
: s.engine === 'yaesu' ? 'yaesu'
|
: s.engine === 'yaesu' ? 'yaesu'
|
||||||
: s.engine === 'serial' ? 'serial'
|
: s.engine === 'kenwood' ? 'kenwood'
|
||||||
: 'winkeyer');
|
: s.engine === 'serial' ? 'serial'
|
||||||
|
: 'winkeyer');
|
||||||
} catch { /* keyer not configured */ }
|
} catch { /* keyer not configured */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -2695,7 +2834,7 @@ export default function App() {
|
|||||||
// segment AFTER the <LOGQSO> (which logs and clears the form) still expands its
|
// segment AFTER the <LOGQSO> (which logs and clears the form) still expands its
|
||||||
// variables correctly.
|
// variables correctly.
|
||||||
const parts = rawText.split(/<LOGQSO>/i).map((pt) => resolveCW(pt));
|
const parts = rawText.split(/<LOGQSO>/i).map((pt) => resolveCW(pt));
|
||||||
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex' || cwSourceRef.current === 'yaesu';
|
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex' || cwSourceRef.current === 'yaesu' || cwSourceRef.current === 'kenwood';
|
||||||
for (let p = 0; p < parts.length; p++) {
|
for (let p = 0; p < parts.length; p++) {
|
||||||
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
|
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
|
||||||
const resolved = parts[p];
|
const resolved = parts[p];
|
||||||
@@ -2707,39 +2846,37 @@ export default function App() {
|
|||||||
// current WPM, so it scales automatically.
|
// current WPM, so it scales automatically.
|
||||||
const keyed = resolved + ' ';
|
const keyed = resolved + ' ';
|
||||||
setWkSent(resolved);
|
setWkSent(resolved);
|
||||||
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : cwSourceRef.current === 'yaesu' ? YaesuSendCW : null;
|
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : cwSourceRef.current === 'yaesu' ? YaesuSendCW : cwSourceRef.current === 'kenwood' ? KenwoodSendCW : null;
|
||||||
if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
|
if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||||
else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||||
// Only WAIT for the CW to finish on the FINAL segment — auto-call needs the
|
// WAIT for THIS segment's CW to finish before moving on — so a <LOGQSO>
|
||||||
// send done before its gap+resend. A segment a <LOGQSO> follows is NOT waited
|
// that follows logs AFTER the text before it (e.g. "TU") has actually been
|
||||||
// on: the keyer has already buffered the CW, so logging happens AT ONCE and
|
// sent, not the instant it's buffered into the keyer. Buffering then logging
|
||||||
// never interrupts what's being sent. (Gating the log on a CW-length ESTIMATE
|
// "at once" made a macro ending in "TU <LOGQSO>" log on the very first
|
||||||
// made <LOGQSO> log up to ~10s late when the estimate ran long.)
|
// letter. Auto-call also needs the send finished before its gap+resend.
|
||||||
if (isLast) {
|
if (isRig) {
|
||||||
if (isRig) {
|
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
|
||||||
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
|
} else {
|
||||||
} else {
|
// WinKeyer / serial keyer: the message takes AT LEAST its own length to
|
||||||
// WinKeyer / serial keyer: the message takes AT LEAST its own length to
|
// send, so the estimate is a floor and the busy signal only extends it.
|
||||||
// send, so the estimate is a floor and the busy signal only extends it.
|
//
|
||||||
//
|
// Waiting on busy alone made the auto-call gap start early: busy travels
|
||||||
// Waiting on busy alone made the auto-call gap start early: busy travels
|
// from the keyer to this window as an event, and if it had not arrived
|
||||||
// from the keyer to this window as an event, and if it had not arrived
|
// and cleared within the one-second start window the wait simply ended.
|
||||||
// and cleared within the one-second start window the wait simply ended.
|
// A 6 s gap then ran from the START of the CQ — about 2 s of silence
|
||||||
// A 6 s gap then ran from the START of the CQ — about 2 s of silence
|
// after a 4 s call, which is what an operator hears as "clearly not 6".
|
||||||
// after a 4 s call, which is what an operator hears as "clearly not 6".
|
const estMs = Math.round(estimateCwMs(resolved, wkWpm));
|
||||||
const estMs = Math.round(estimateCwMs(resolved, wkWpm));
|
const started = Date.now();
|
||||||
const started = Date.now();
|
const capMs = Math.round(estMs * 1.4) + 2500;
|
||||||
const capMs = Math.round(estMs * 1.4) + 2500;
|
const deadline = started + capMs;
|
||||||
const deadline = started + capMs;
|
// The message's own duration, first — this is the part that must not be skipped.
|
||||||
// The message's own duration, first — this is the part that must not be skipped.
|
while (Date.now() - started < estMs && !aborted()) await sleep(50);
|
||||||
while (Date.now() - started < estMs && !aborted()) await sleep(50);
|
// Then let a still-sending keyer finish (slow link, long buffer), capped.
|
||||||
// Then let a still-sending keyer finish (slow link, long buffer), capped.
|
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
|
||||||
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (aborted()) return; // aborted while this segment was sending → don't log
|
if (aborted()) return; // aborted while this segment was sending → don't log
|
||||||
if (logAfter) void save(); // log NOW — the buffered CW keeps sending
|
if (logAfter) void save(); // the preceding CW has finished → log at the token's spot
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// stopAutoCall cancels any running auto-call loop.
|
// stopAutoCall cancels any running auto-call loop.
|
||||||
@@ -2751,6 +2888,7 @@ export default function App() {
|
|||||||
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
||||||
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
||||||
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
|
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
|
||||||
|
else if (cwSourceRef.current === 'kenwood') KenwoodStopCW().catch(() => {});
|
||||||
else WinkeyerStop().catch(() => {});
|
else WinkeyerStop().catch(() => {});
|
||||||
}
|
}
|
||||||
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
|
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
|
||||||
@@ -2864,6 +3002,8 @@ export default function App() {
|
|||||||
worked_call: !!(r as any).worked_call,
|
worked_call: !!(r as any).worked_call,
|
||||||
new_county: !!(r as any).new_county,
|
new_county: !!(r as any).new_county,
|
||||||
new_pota: !!(r as any).new_pota,
|
new_pota: !!(r as any).new_pota,
|
||||||
|
new_pfx: !!(r as any).new_pfx,
|
||||||
|
pfx: (r as any).pfx,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
@@ -2972,7 +3112,11 @@ export default function App() {
|
|||||||
setWb(null); // clear the Worked-before grid for the just-cleared callsign
|
setWb(null); // clear the Worked-before grid for the just-cleared callsign
|
||||||
setLookupError('');
|
setLookupError('');
|
||||||
rstUserEditedRef.current = false;
|
rstUserEditedRef.current = false;
|
||||||
applyModePreset(mode);
|
// modeRef, not `mode`: resetEntry is reached from the global key handler,
|
||||||
|
// whose effect does not re-run on a mode change. Pressing ESC during a CW
|
||||||
|
// macro therefore reset the report using the mode as it was when that
|
||||||
|
// handler was built — 'SSB' — and turned 599 into 59.
|
||||||
|
applyModePreset(modeRef.current);
|
||||||
setDetails((d) => ({
|
setDetails((d) => ({
|
||||||
...d,
|
...d,
|
||||||
state: '', cnty: '', address: '', lat: undefined, lon: undefined,
|
state: '', cnty: '', address: '', lat: undefined, lon: undefined,
|
||||||
@@ -3224,10 +3368,16 @@ export default function App() {
|
|||||||
if (!ue.has('name') && empty(r?.name) && last.name) setName(last.name);
|
if (!ue.has('name') && empty(r?.name) && last.name) setName(last.name);
|
||||||
if (!ue.has('qth') && empty(r?.qth) && last.qth) setQth(last.qth);
|
if (!ue.has('qth') && empty(r?.qth) && last.qth) setQth(last.qth);
|
||||||
if (!ue.has('country') && empty(r?.country) && last.country) setCountry(last.country);
|
if (!ue.has('country') && empty(r?.country) && last.country) setCountry(last.country);
|
||||||
// Grid: a REAL provider grid always wins. Otherwise the last QSO's precise
|
// Grid: the provider (QRZ / HamQTH) leads, but only while it is at least as
|
||||||
// locator beats the coarse cty.dat entity centroid the provider block set — so
|
// PRECISE as what we already worked. It used to lead whenever it answered at
|
||||||
// a French call resolves to its real JNxx, not the country's JN16 centroid.
|
// all, so a 4-character square — which is what a provider returns when the
|
||||||
const adoptLastGrid = !ue.has('grid') && empty(r?.grid) && !!last.grid;
|
// station only published a rough locator, and what the cty.dat entity
|
||||||
|
// centroid gives — overwrote the full 6-character locator confirmed on a
|
||||||
|
// previous contact. Comparing lengths keeps the finer of the two, whichever
|
||||||
|
// side it came from.
|
||||||
|
const provGrid = String(r?.grid ?? '').trim();
|
||||||
|
const lastGrid = String(last.grid ?? '').trim();
|
||||||
|
const adoptLastGrid = !ue.has('grid') && !!lastGrid && lastGrid.length > provGrid.length;
|
||||||
if (adoptLastGrid) setGrid(last.grid);
|
if (adoptLastGrid) setGrid(last.grid);
|
||||||
setDetails((d) => {
|
setDetails((d) => {
|
||||||
// When we adopt the last QSO's locator, take its coordinates too (derive them
|
// When we adopt the last QSO's locator, take its coordinates too (derive them
|
||||||
@@ -3549,6 +3699,8 @@ export default function App() {
|
|||||||
{ type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
|
{ type: 'item' as const, label: sendingLog ? t('help.sendLogBusy') : t('help.sendLog'), action: 'help.sendlog', disabled: sendingLog },
|
||||||
] : []),
|
] : []),
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
|
{ type: 'item', label: t('logview.title'), action: 'help.log' },
|
||||||
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: t('help.donate'), action: 'help.donate', accent: true },
|
{ type: 'item', label: t('help.donate'), action: 'help.donate', accent: true },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
||||||
@@ -3582,6 +3734,7 @@ export default function App() {
|
|||||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||||
case 'tools.downloadRefs': downloadRefs(); break;
|
case 'tools.downloadRefs': downloadRefs(); break;
|
||||||
case 'help.about': setShowAbout(true); checkUpdateNow(); break;
|
case 'help.about': setShowAbout(true); checkUpdateNow(); break;
|
||||||
|
case 'help.log': setShowLogViewer(true); break;
|
||||||
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
|
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
|
||||||
case 'help.sendlog': sendLogToDeveloper(); break;
|
case 'help.sendlog': sendLogToDeveloper(); break;
|
||||||
// Opens in the system browser, NOT the app WebView: a payment page must show
|
// Opens in the system browser, NOT the app WebView: a payment page must show
|
||||||
@@ -3663,6 +3816,7 @@ export default function App() {
|
|||||||
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
||||||
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
|
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
|
||||||
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
||||||
|
else if (cwSourceRef.current === 'kenwood') KenwoodStopCW().catch(() => {});
|
||||||
else WinkeyerStop().catch(() => {});
|
else WinkeyerStop().catch(() => {});
|
||||||
}
|
}
|
||||||
if (!keyerLive || wkEscClearsRef.current) {
|
if (!keyerLive || wkEscClearsRef.current) {
|
||||||
@@ -3990,8 +4144,14 @@ export default function App() {
|
|||||||
const cls = d >= 0 && d < 7 ? 'border-success text-success bg-success/15'
|
const cls = d >= 0 && d < 7 ? 'border-success text-success bg-success/15'
|
||||||
: d < 30 ? 'border-warning text-warning bg-warning/15'
|
: d < 30 ? 'border-warning text-warning bg-warning/15'
|
||||||
: 'border-danger text-danger bg-danger/15';
|
: 'border-danger text-danger bg-danger/15';
|
||||||
|
// ARRL regenerates lotw-user-activity.csv on its own schedule and has been
|
||||||
|
// seen frozen for a week (2026-08-02: newest line 2026-07-27). "Uploaded 7
|
||||||
|
// days ago" then says nothing about the station, so the tooltip names the
|
||||||
|
// day the feed itself was built whenever that day is not recent.
|
||||||
|
const tip = t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })
|
||||||
|
+ (lotwInfo.feed_stale && lotwInfo.feed_date ? ' — ' + t('lotw.feedStale', { date: lotwInfo.feed_date }) : '');
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0" title={t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })}>
|
<div className="shrink-0" title={tip}>
|
||||||
<span className={cn('inline-flex items-center rounded-md border px-1.5 py-1 text-[9px] font-extrabold tracking-wide leading-none', cls)}>
|
<span className={cn('inline-flex items-center rounded-md border px-1.5 py-1 text-[9px] font-extrabold tracking-wide leading-none', cls)}>
|
||||||
LoTW
|
LoTW
|
||||||
</span>
|
</span>
|
||||||
@@ -4299,7 +4459,8 @@ export default function App() {
|
|||||||
const matches = clusterStatusFilter.has(st)
|
const matches = clusterStatusFilter.has(st)
|
||||||
|| (!!e?.worked_call && clusterStatusFilter.has('worked'))
|
|| (!!e?.worked_call && clusterStatusFilter.has('worked'))
|
||||||
|| (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
|
|| (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
|
||||||
|| (!!e?.new_county && clusterStatusFilter.has('new-county'));
|
|| (!!e?.new_county && clusterStatusFilter.has('new-county'))
|
||||||
|
|| (!!e?.new_pfx && clusterStatusFilter.has('new-pfx'));
|
||||||
if (!matches) return false;
|
if (!matches) return false;
|
||||||
}
|
}
|
||||||
if (clusterHideWorked) {
|
if (clusterHideWorked) {
|
||||||
@@ -4418,7 +4579,8 @@ export default function App() {
|
|||||||
// Same colours as the badges in the grid — a filter that does not
|
// Same colours as the badges in the grid — a filter that does not
|
||||||
// look like what it selects has to be learned twice.
|
// look like what it selects has to be learned twice.
|
||||||
{ k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
{ k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
||||||
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
|
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
||||||
|
{ k: 'new-pfx' as SpotFilterKey, label: 'NEW PFX', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||||
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
|
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
|
||||||
]).map((s) => {
|
]).map((s) => {
|
||||||
const on = clusterStatusFilter.has(s.k);
|
const on = clusterStatusFilter.has(s.k);
|
||||||
@@ -4819,6 +4981,60 @@ export default function App() {
|
|||||||
{showTuner && tgStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
|
{showTuner && tgStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* Amplifier widget. With one amp the icon is a plain toggle; with
|
||||||
|
several it also carries a dropdown to choose which one the widget
|
||||||
|
shows (or All), since two amps stacked make a tall widget. */}
|
||||||
|
{ampSts.length > 0 && (
|
||||||
|
<div className="relative inline-flex">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { const v = !showAmpWidget; setShowAmpWidget(v); writeUiPref('opslog.showAmpWidget', v ? '1' : '0'); }}
|
||||||
|
title={showAmpWidget ? t('ampw.hideHint') : t('ampw.showHint')}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||||
|
ampSts.length > 1 && 'rounded-r-none border-r-0',
|
||||||
|
showAmpWidget ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||||
|
: 'border-border text-muted-foreground hover:bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Flame className="size-4" />
|
||||||
|
{showAmpWidget && ampSts.some((a: any) => a.spe?.connected || a.acom?.connected || a.pgxl?.connected || flexAmp?.amp_available) && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{ampSts.length > 1 && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button type="button" title={t('ampw.pick')}
|
||||||
|
className={cn('inline-flex items-center justify-center h-7 w-4 rounded-md rounded-l-none border transition-colors',
|
||||||
|
showAmpWidget ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||||
|
: 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
<ChevronDown className="size-3" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="min-w-44">
|
||||||
|
{[{ id: 'all', name: t('ampw.all') }, ...ampSts.map((a: any) => ({ id: a.id, name: a.name || a.id }))].map((o) => (
|
||||||
|
<DropdownMenuItem key={o.id}
|
||||||
|
onSelect={() => {
|
||||||
|
setAmpWidgetSel(o.id);
|
||||||
|
writeUiPref('opslog.ampSel.widget', o.id);
|
||||||
|
// Picking an amp means "show me this one" — opening the
|
||||||
|
// widget too saves a second click on a hidden one.
|
||||||
|
if (!showAmpWidget) { setShowAmpWidget(true); writeUiPref('opslog.showAmpWidget', '1'); }
|
||||||
|
}}>
|
||||||
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
|
{ampWidgetSel === o.id
|
||||||
|
? <Check className="size-3.5 shrink-0 text-primary" />
|
||||||
|
: <span className="size-3.5 shrink-0" />}
|
||||||
|
<span className="truncate">{o.name}</span>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{scpEnabled && (
|
{scpEnabled && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -5123,6 +5339,9 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Live diagnostic log (Help → …). Polls only while open. */}
|
||||||
|
<LogViewer open={showLogViewer} onOpenChange={setShowLogViewer} />
|
||||||
|
|
||||||
{/* What's new — shown once on the first launch after an update. */}
|
{/* What's new — shown once on the first launch after an update. */}
|
||||||
{whatsNew && (
|
{whatsNew && (
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => setWhatsNew(null)}>
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => setWhatsNew(null)}>
|
||||||
@@ -5335,7 +5554,7 @@ export default function App() {
|
|||||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||||
otherwise it shows the QRZ profile photo. */}
|
otherwise it shows the QRZ profile photo. */}
|
||||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showScp && scpEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showAmpWidget && ampSts.length > 0) || (showScp && scpEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
||||||
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
|
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
|
||||||
// the DVK with Auto CQ) can't grow the row — the row height stays set by
|
// the DVK with Auto CQ) can't grow the row — the row height stays set by
|
||||||
// the entry strip and each widget fills that height, scrolling inside.
|
// the entry strip and each widget fills that height, scrolling inside.
|
||||||
@@ -5407,6 +5626,9 @@ export default function App() {
|
|||||||
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
|
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
|
||||||
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
|
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
|
||||||
rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
|
rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
|
||||||
|
rotors={(rotatorHeading as any).rotors}
|
||||||
|
activeRotor={(rotatorHeading as any).active}
|
||||||
|
onSelectRotor={(i) => { SetActiveRotor(i).then(() => GetRotatorHeading()).then((h: any) => setRotatorHeading(h)).catch((err) => setError(String(err?.message ?? err))); }}
|
||||||
onGoto={(az) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)))}
|
onGoto={(az) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)))}
|
||||||
onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
|
onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
|
||||||
/>
|
/>
|
||||||
@@ -5422,6 +5644,19 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showAmpWidget && ampSts.length > 0 && (
|
||||||
|
// One column per amplifier shown, so two amps stand side by side
|
||||||
|
// rather than making the widget twice as tall as the dock row.
|
||||||
|
<div className="shrink-0 min-h-0"
|
||||||
|
style={{ width: `${Math.min(ampWidgetSel === 'all' ? ampSts.length : 1, 3) * 250 + 20}px` }}>
|
||||||
|
<AmpWidget
|
||||||
|
amps={ampSts}
|
||||||
|
flex={flexAmp}
|
||||||
|
sel={ampWidgetSel}
|
||||||
|
onClose={() => { setShowAmpWidget(false); writeUiPref('opslog.showAmpWidget', '0'); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showTuner && tgEnabled && (
|
{showTuner && tgEnabled && (
|
||||||
<div className="w-[230px] shrink-0 min-h-0">
|
<div className="w-[230px] shrink-0 min-h-0">
|
||||||
<TunerGeniusPanel
|
<TunerGeniusPanel
|
||||||
@@ -5468,11 +5703,11 @@ export default function App() {
|
|||||||
// exactly when its CAT backend is. Yaesu was missing from this
|
// exactly when its CAT backend is. Yaesu was missing from this
|
||||||
// list, so the panel fell back to the WinKeyer status — which
|
// list, so the panel fell back to the WinKeyer status — which
|
||||||
// reported disconnected, no WinKeyer being attached.
|
// reported disconnected, no WinKeyer being attached.
|
||||||
status={cwSource === 'icom' || cwSource === 'flex' || cwSource === 'yaesu'
|
status={cwSource === 'icom' || cwSource === 'flex' || cwSource === 'yaesu' || cwSource === 'kenwood'
|
||||||
? {
|
? {
|
||||||
connected: catState.backend === cwSource && catState.connected,
|
connected: catState.backend === cwSource && catState.connected,
|
||||||
busy: false, wpm: wkWpm, version: 0,
|
busy: false, wpm: wkWpm, version: 0,
|
||||||
port: cwSource === 'flex' ? 'CWX' : cwSource === 'yaesu' ? 'CAT' : 'CI-V',
|
port: cwSource === 'flex' ? 'CWX' : cwSource === 'yaesu' || cwSource === 'kenwood' ? 'CAT' : 'CI-V',
|
||||||
}
|
}
|
||||||
: wkStatus}
|
: wkStatus}
|
||||||
ports={wkPorts}
|
ports={wkPorts}
|
||||||
@@ -6018,8 +6253,23 @@ export default function App() {
|
|||||||
{/* Two configurable panes (per-profile, Settings → Main view).
|
{/* Two configurable panes (per-profile, Settings → Main view).
|
||||||
Each side shows one of: great-circle map, locator map, cluster
|
Each side shows one of: great-circle map, locator map, cluster
|
||||||
or worked-before. */}
|
or worked-before. */}
|
||||||
<div className="grid grid-cols-2 grid-rows-1 gap-2 h-full min-h-0 p-2">
|
{/* The divider is draggable: a map and a cluster list want very
|
||||||
|
different widths, and which one deserves the room changes with
|
||||||
|
what the operator is doing. The share is persisted (portable),
|
||||||
|
and a double-click puts it back to even. */}
|
||||||
|
<div ref={mainSplitRef} className="grid grid-rows-1 gap-2 h-full min-h-0 p-2"
|
||||||
|
style={{ gridTemplateColumns: `${mainSplit}fr 6px ${100 - mainSplit}fr` }}>
|
||||||
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneLeft)}</div>
|
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneLeft)}</div>
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
title={t('main.splitTip')}
|
||||||
|
onPointerDown={startMainSplitDrag}
|
||||||
|
onDoubleClick={() => setMainSplit(50)}
|
||||||
|
className="group relative cursor-col-resize flex items-center justify-center -mx-1 px-1"
|
||||||
|
>
|
||||||
|
<span className="h-10 w-1 rounded-full bg-border group-hover:bg-primary transition-colors" />
|
||||||
|
</div>
|
||||||
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneRight)}</div>
|
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneRight)}</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -51,11 +51,14 @@ type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgx
|
|||||||
|
|
||||||
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
|
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
|
||||||
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
|
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
|
||||||
|
// One second: the same window as the docked widget, so the two never show
|
||||||
|
// different figures for the same amplifier, and neither trails the end of a
|
||||||
|
// transmission long enough to look like lag.
|
||||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||||
const peakHold = (key: string, val: number) => {
|
const peakHold = (key: string, val: number) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const p = peak.current[key];
|
const p = peak.current[key];
|
||||||
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
if (!p || val >= p.v || now - p.t > 1000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||||
return p.v;
|
return p.v;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,9 +76,12 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
</button>
|
</button>
|
||||||
|
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
|
||||||
|
is off (no status = not "connected"), and serial only. */}
|
||||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||||
<button type="button" disabled={!spe.connected}
|
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
|
||||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||||
|
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
|
||||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||||
<button type="button" disabled={!spe.connected}
|
<button type="button" disabled={!spe.connected}
|
||||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||||
@@ -177,9 +183,6 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
{operate ? 'OPERATE' : 'STANDBY'}
|
{operate ? 'OPERATE' : 'STANDBY'}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
|
|
||||||
</span>
|
|
||||||
{(pg.host || pg.connected) && (
|
{(pg.host || pg.connected) && (
|
||||||
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
||||||
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||||
@@ -208,6 +211,11 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||||
if (amps.length === 0) return null;
|
if (amps.length === 0) return null;
|
||||||
|
// Power comes from the radio's meter stream and nothing else. The
|
||||||
|
// amplifier also reports a "peakfwd", and using it was a mistake twice
|
||||||
|
// over: it is a latched maximum that is never reset, and it survives in
|
||||||
|
// the last-known status after the amp disconnects — so it claimed
|
||||||
|
// 1350 W from an old transmission while the radio was putting out 10.
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
{amps.map((m) => {
|
{amps.map((m) => {
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { Flame, X, Power } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
|
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// AmpWidget — the docked amplifier widget, the compact counterpart of AmpCard
|
||||||
|
// (which is the full-size card shown in the FlexRadio panel and Station
|
||||||
|
// Control). It covers all three families, since an operator running an SPE and
|
||||||
|
// a PowerGenius wants one widget, not two:
|
||||||
|
// • SPE Expert — OPERATE, power ON/OFF, L/M/H, live power / SWR / temp
|
||||||
|
// • ACOM — OPERATE, power ON/OFF, live power / SWR / temp
|
||||||
|
// • PowerGenius — OPERATE and meters ride on the FlexRadio, fan mode on GSCP
|
||||||
|
// With several amplifiers configured the caller passes a selection ("all" or an
|
||||||
|
// amp id) chosen from the toolbar icon's dropdown.
|
||||||
|
|
||||||
|
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
|
||||||
|
|
||||||
|
// Full-scale watts for the output bar, from the model string.
|
||||||
|
function maxW(model?: string, fallback = 1300): number {
|
||||||
|
const m = (model || '').toUpperCase();
|
||||||
|
if (m.includes('20K') || m.includes('2K')) return 2000;
|
||||||
|
if (m.includes('15K')) return 1500;
|
||||||
|
if (m.includes('13K')) return 1300;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function swrColour(swr?: number): string {
|
||||||
|
if (!swr || swr <= 0) return 'text-muted-foreground';
|
||||||
|
if (swr <= 1.5) return 'text-success';
|
||||||
|
if (swr <= 2.0) return 'text-warning';
|
||||||
|
return 'text-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
// PowerMeter — the output-power LED bar that closes each column, straight under
|
||||||
|
// the controls (no filler above it: pushed to the bottom it left a band of dead
|
||||||
|
// space in the middle of the column). Uses the app-wide MeterBar, compact size,
|
||||||
|
// so it reads exactly like the meters on the rig panels.
|
||||||
|
function PowerMeter({ label, watts, maxWatts }: { label: string; watts: number; maxWatts: number }) {
|
||||||
|
return (
|
||||||
|
<MeterBar compact label={label} value={watts} unit="W" lo={0} hi={maxWatts}
|
||||||
|
display={`${Math.round(watts)} W`}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// usePeakHold keeps the highest reading seen for a short window, so a reading
|
||||||
|
// sampled between two syllables doesn't collapse the meter. `live` is the key
|
||||||
|
// part: pass false the moment the carrier drops and the held value is thrown
|
||||||
|
// away at once — holding it there made releasing the PTT look like a five
|
||||||
|
// second lag, which read as the whole app being slow.
|
||||||
|
function usePeakHold(windowMs = 1000) {
|
||||||
|
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||||
|
return (key: string, val: number, live = true) => {
|
||||||
|
if (!live) { delete peak.current[key]; return val; }
|
||||||
|
const now = Date.now();
|
||||||
|
const p = peak.current[key];
|
||||||
|
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
|
||||||
|
return p.v;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat — one small labelled figure (power, SWR, temperature).
|
||||||
|
function Stat({ label, value, className }: { label: string; value: string; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card/70 px-1.5 py-1 text-center">
|
||||||
|
<div className="text-[9px] uppercase tracking-wider text-muted-foreground leading-tight">{label}</div>
|
||||||
|
<div className={cn('text-sm font-bold font-mono leading-tight', className ?? 'text-foreground/80')}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// OperateButton — the STANDBY/OPERATE toggle, identical in behaviour across the
|
||||||
|
// three families (only where the state comes from differs).
|
||||||
|
function OperateButton({ operate, disabled, onClick, t }: {
|
||||||
|
operate: boolean; disabled: boolean; onClick: () => void; t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button type="button" disabled={disabled} onClick={onClick}
|
||||||
|
className={cn('w-full inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-[0.98] disabled:opacity-40',
|
||||||
|
operate
|
||||||
|
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_9px_rgba(245,158,11,0.45)]'
|
||||||
|
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground')}>
|
||||||
|
<Power className="size-3.5" />
|
||||||
|
{operate ? t('ampw.operate') : t('ampw.standby')}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AmpBlock — one amplifier inside the widget.
|
||||||
|
function AmpBlock({ amp, flex, showName, t }: {
|
||||||
|
amp: Amp; flex: any; showName: boolean; t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const spe = amp.spe, acom = amp.acom;
|
||||||
|
const hold = usePeakHold();
|
||||||
|
|
||||||
|
if (spe || acom) {
|
||||||
|
const s = spe || acom;
|
||||||
|
// The amp reports zero watts on receive, so its TX flag clears the meter as
|
||||||
|
// soon as the operator lets go — and the radio's own flag, when we have one,
|
||||||
|
// gets there first (the amp is polled on its own slower cycle).
|
||||||
|
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : s.tx !== false;
|
||||||
|
const w = hold('w', Number(spe ? s.output_w : s.fwd_w) || 0, txing);
|
||||||
|
const swr = Number(spe ? s.swr_ant : s.swr) || 0;
|
||||||
|
const hi = spe ? maxW(s.model) : (Number(s.max_w) || 800);
|
||||||
|
// Power ON drives the remote-on control lines, so it stays available while
|
||||||
|
// the amplifier is off and reporting nothing — but only over a serial link.
|
||||||
|
const canPowerOn = spe ? (s.connected || s.transport === 'serial') : (s.port_open && s.transport === 'serial');
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col gap-1.5">
|
||||||
|
{showName && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={cn('size-1.5 rounded-full shrink-0', s.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : 'ACOM')}</span>
|
||||||
|
{s.connected && s.band && <span className="ml-auto text-[9px] text-muted-foreground shrink-0">{s.band}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<OperateButton operate={!!s.operate} disabled={!s.connected} t={t}
|
||||||
|
onClick={() => AmpOperate(amp.id, !s.operate).catch(() => {})} />
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button type="button" disabled={!canPowerOn}
|
||||||
|
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||||
|
className="flex-1 rounded-lg border border-success/60 bg-card py-1 text-[11px] font-bold text-success hover:bg-success/15 disabled:opacity-30">
|
||||||
|
{t('ampw.on')}
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={!s.connected}
|
||||||
|
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||||
|
className="flex-1 rounded-lg border border-danger/60 bg-card py-1 text-[11px] font-bold text-danger hover:bg-danger/15 disabled:opacity-30">
|
||||||
|
{t('ampw.off')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Output level — SPE only; an ACOM has no L/M/H over CAT. */}
|
||||||
|
{spe && (
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
{(['L', 'M', 'H'] as const).map((lvl) => {
|
||||||
|
const active = (s.power_level || '').trim().toUpperCase() === lvl;
|
||||||
|
return (
|
||||||
|
<button key={lvl} type="button" disabled={!s.connected}
|
||||||
|
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
|
||||||
|
className={cn('rounded-md border py-0.5 text-[10px] font-bold transition-colors disabled:opacity-30',
|
||||||
|
active ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
{t(`ampw.lvl${lvl}`)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{s.connected ? (
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
<Stat label={t('ampw.pwr')} value={`${Math.round(w)}`} />
|
||||||
|
<Stat label={t('ampw.swr')} value={swr > 0 ? swr.toFixed(1) : '—'} className={swrColour(swr)} />
|
||||||
|
<Stat label={t('ampw.temp')} value={`${Number(s.temp_c) || 0}°`} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
||||||
|
)}
|
||||||
|
{(s.warnings || s.alarms || s.err_text) && (
|
||||||
|
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
|
||||||
|
{s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<PowerMeter label={t('flxp.outputPower')} watts={s.connected ? w : 0} maxWatts={hi} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PowerGenius XL: OPERATE and the meters come from the radio when the Flex
|
||||||
|
// reports the amp; the fan mode rides on our own GSCP link.
|
||||||
|
const pg = amp.pgxl || {};
|
||||||
|
const viaFlex = !!flex?.amp_available;
|
||||||
|
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
||||||
|
const connected = !!pg.connected || viaFlex;
|
||||||
|
const fault = flex?.amp_fault;
|
||||||
|
const meters = ((flex?.meters as any[]) || []).filter((m) => (m.src || '').toUpperCase().includes('AMP'));
|
||||||
|
const meter = (re: RegExp) => meters.find((m) => re.test((m.name || '').trim()));
|
||||||
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
|
const fwd = meter(/^fwd|pwr/i);
|
||||||
|
const flexW = fwd ? (/dbm/i.test(fwd.unit || '') ? dbmToW(fwd.value) : fwd.value) : 0;
|
||||||
|
// Whether there is power is the RADIO's transmit flag: it flips the instant
|
||||||
|
// the PTT does, where the amplifier's own status poll answers a second and a
|
||||||
|
// half late and holds TRANSMIT past the carrier.
|
||||||
|
//
|
||||||
|
// How MUCH is the live reading — the radio's meter stream, or the amp's own
|
||||||
|
// forward figure when there is no Flex. NOT the amp's "peakfwd": that is a
|
||||||
|
// latched maximum which is never reset AND survives in the last-known status
|
||||||
|
// after the amp disconnects, so it reported 1350 W from an old transmission
|
||||||
|
// while the radio was putting out 10 W into a disconnected amplifier.
|
||||||
|
const txing = typeof flex?.transmitting === 'boolean'
|
||||||
|
? flex.transmitting
|
||||||
|
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
|
||||||
|
// Prefer the radio's VITA meter stream when it is actually flowing (local or
|
||||||
|
// over a VPN) — it is fast and matches SmartSDR. Fall back to the amp's OWN
|
||||||
|
// link only when the radio isn't streaming its meters (a remote link without
|
||||||
|
// the VITA path), so the reading survives but is naturally slower.
|
||||||
|
const liveW = flexW > 0 ? flexW : (pg.connected ? Number(pg.fwd_w) || 0 : 0);
|
||||||
|
const fwdW = hold('fwd', txing ? liveW : 0, txing);
|
||||||
|
const id = meter(/^ID$|current/i);
|
||||||
|
// Live drain current, same source preference. peak_id latches like peak_w, so
|
||||||
|
// use the instantaneous 'id' and gate it on the radio's transmit flag.
|
||||||
|
const idA = id ? Number(id.value) : (txing ? Number(pg.id) || undefined : 0);
|
||||||
|
const temp = meter(/temp/i);
|
||||||
|
// VITA temp matches SmartSDR (the amp's direct link exposes a different sensor
|
||||||
|
// that can read ~10°C higher); fall back to the direct temp when no VITA.
|
||||||
|
const tempC = temp ? temp.value : (pg.temperature > 0 ? pg.temperature : undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col gap-1.5">
|
||||||
|
{showName && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={cn('size-1.5 rounded-full shrink-0', connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || 'PGXL'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<OperateButton operate={operate} disabled={!connected} t={t}
|
||||||
|
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})} />
|
||||||
|
{connected ? (
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
<Stat label={t('ampw.pwr')} value={`${Math.round(fwdW)}`} />
|
||||||
|
<Stat label={t('ampw.id')} value={idA != null ? idA.toFixed(1) : '—'} />
|
||||||
|
<Stat label={t('ampw.temp')} value={tempC != null ? `${Math.round(tempC)}°` : '—'} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
||||||
|
)}
|
||||||
|
{(pg.host || pg.connected) && (
|
||||||
|
<select
|
||||||
|
disabled={!pg.connected}
|
||||||
|
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||||||
|
onChange={(e) => AmpFanMode(amp.id, e.target.value).catch(() => {})}
|
||||||
|
title={t('ampw.fan')}
|
||||||
|
className="w-full h-7 rounded-md border border-border bg-card px-1.5 text-[10px] font-semibold outline-none focus:border-warning disabled:opacity-40">
|
||||||
|
<option value="STANDARD">{t('flxp.fanStandard')}</option>
|
||||||
|
<option value="CONTEST">{t('flxp.fanContest')}</option>
|
||||||
|
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{fault && fault !== 'NONE' && (
|
||||||
|
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
|
||||||
|
{t('flxp.fault')}: {fault}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<PowerMeter label={t('flxp.outputPower')} watts={connected ? fwdW : 0} maxWatts={2000} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmpWidget({ amps, flex, sel, onClose }: {
|
||||||
|
amps: Amp[];
|
||||||
|
flex: any;
|
||||||
|
sel: string; // "all" or an amp id
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
// An id that no longer matches (the amp was removed in Settings) falls back to
|
||||||
|
// showing everything rather than an empty widget.
|
||||||
|
const shown = sel === 'all' ? amps : (amps.filter((a) => a.id === sel).length ? amps.filter((a) => a.id === sel) : amps);
|
||||||
|
const anyOnline = shown.some((a) => a.spe?.connected || a.acom?.connected || a.pgxl?.connected || flex?.amp_available);
|
||||||
|
const title = shown.length === 1 ? (shown[0].name || t('ampw.title')) : t('ampw.title');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||||
|
<Flame className={cn('size-4', anyOnline ? 'text-warning drop-shadow-[0_0_3px_rgba(245,158,11,0.55)]' : 'text-muted-foreground')} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80 truncate">{title}</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('ampw.close')}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Several amplifiers sit SIDE BY SIDE, one column each: stacked, two amps
|
||||||
|
made a widget taller than the dock row and both ended up half hidden. */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-2.5">
|
||||||
|
{shown.length === 0 ? (
|
||||||
|
<div className="text-[11px] text-center text-muted-foreground italic py-2">{t('ampw.none')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2.5 h-full" style={{ gridTemplateColumns: `repeat(${shown.length}, minmax(0, 1fr))` }}>
|
||||||
|
{shown.map((a, i) => (
|
||||||
|
<div key={a.id} className={cn(i > 0 && 'pl-2.5 border-l border-border/50')}>
|
||||||
|
<AmpBlock amp={a} flex={flex} showName={shown.length > 1} t={t} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -69,6 +69,9 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
||||||
// Computed results are cached per award code — each award is scanned only the
|
// Computed results are cached per award code — each award is scanned only the
|
||||||
// first time it's selected (or when explicitly rescanned).
|
// first time it's selected (or when explicitly rescanned).
|
||||||
|
// Keyed by "CODE|MODECLASS": the same award computed for CW and for all modes
|
||||||
|
// are different results, and caching them under one key showed the previous
|
||||||
|
// mode’s numbers after switching.
|
||||||
const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
|
const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
@@ -119,13 +122,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
// Compute one award (cached). force=true bypasses the cache (Rescan).
|
// Compute one award (cached). force=true bypasses the cache (Rescan).
|
||||||
async function compute(code: string, force = false) {
|
async function compute(code: string, force = false) {
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
if (!force && byCode[code]) { setSelected(code); return; }
|
const key = `${code}|${modeFilter}`;
|
||||||
|
if (!force && byCode[key]) { setSelected(code); return; }
|
||||||
setSelected(code);
|
setSelected(code);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErr('');
|
setErr('');
|
||||||
try {
|
try {
|
||||||
const r = (await GetAward(code)) as any as AwardResult;
|
const r = (await GetAward(code, modeFilter === 'all' ? '' : modeFilter)) as any as AwardResult;
|
||||||
setByCode((m) => ({ ...m, [code]: r }));
|
setByCode((m) => ({ ...m, [key]: r }));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(String(e?.message ?? e));
|
setErr(String(e?.message ?? e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -149,7 +153,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
}
|
}
|
||||||
useEffect(() => { loadList(); }, []);
|
useEffect(() => { loadList(); }, []);
|
||||||
|
|
||||||
const current = byCode[selected];
|
const current = byCode[`${selected}|${modeFilter}`];
|
||||||
|
// Recompute when the mode class changes: the bands, counts and confirmations
|
||||||
|
// all describe the selected mode, so they cannot be filtered client-side.
|
||||||
|
useEffect(() => { if (selected) compute(selected); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [modeFilter]);
|
||||||
|
|
||||||
// Bands relevant to the selected award, used by BOTH the grid and the stats
|
// Bands relevant to the selected award, used by BOTH the grid and the stats
|
||||||
// matrix so neither is padded with bands the award doesn't use. Rule: the
|
// matrix so neither is padded with bands the award doesn't use. Rule: the
|
||||||
@@ -292,7 +299,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
|
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
|
||||||
{awardList.map((a) => {
|
{awardList.map((a) => {
|
||||||
const r = byCode[a.code];
|
const r = byCode[`${a.code}|${modeFilter}`];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={a.code}
|
key={a.code}
|
||||||
@@ -539,7 +546,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cell && current && (
|
{cell && current && (
|
||||||
<CellQSOModal code={current.code} cell={cell} onClose={() => setCell(null)} />
|
<CellQSOModal code={current.code} cell={cell} modeClass={modeFilter === 'all' ? '' : modeFilter} onClose={() => setCell(null)} />
|
||||||
)}
|
)}
|
||||||
{showMissing && current && (
|
{showMissing && current && (
|
||||||
<MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
|
<MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
|
||||||
@@ -724,17 +731,17 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
|
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
|
||||||
function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; onClose: () => void }) {
|
function CellQSOModal({ code, cell, modeClass, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; modeClass: string; onClose: () => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [qsos, setQsos] = useState<any[]>([]);
|
const [qsos, setQsos] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
AwardCellQSOs(code, cell.ref, cell.band)
|
AwardCellQSOs(code, cell.ref, cell.band, modeClass)
|
||||||
.then((r) => setQsos((r ?? []) as any))
|
.then((r) => setQsos((r ?? []) as any))
|
||||||
.catch(() => setQsos([]))
|
.catch(() => setQsos([]))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [code, cell.ref, cell.band]);
|
}, [code, cell.ref, cell.band, modeClass]);
|
||||||
|
|
||||||
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
|
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Star, Radio } from 'lucide-react';
|
import { Star, Radio, Sunrise, Sunset } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { sunTimes } from '@/lib/sun';
|
||||||
import type { WorkedBeforeView } from '@/types';
|
import type { WorkedBeforeView } from '@/types';
|
||||||
|
|
||||||
type WorkedBefore = WorkedBeforeView;
|
type WorkedBefore = WorkedBeforeView;
|
||||||
@@ -13,6 +14,10 @@ interface Props {
|
|||||||
currentMode: string;
|
currentMode: string;
|
||||||
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
||||||
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
||||||
|
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
|
||||||
|
// to an entity with no position at all, and the block simply does not appear.
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
|
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
|
||||||
@@ -84,7 +89,7 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
|
|||||||
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true }: Props) {
|
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon }: Props) {
|
||||||
// Columns from the operator's configured bands (so the matrix shows only the
|
// Columns from the operator's configured bands (so the matrix shows only the
|
||||||
// bands they actually use), falling back to the built-in default set.
|
// bands they actually use), falling back to the built-in default set.
|
||||||
const cols = useMemo(
|
const cols = useMemo(
|
||||||
@@ -151,6 +156,36 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
</Badge>
|
</Badge>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
// Sunrise / sunset AT THE DX, in UTC. A glance tells you whether the path is
|
||||||
|
// about to open or close on their side, which is the reason to look at a DX
|
||||||
|
// station's grey line at all. Recomputed only when the position changes.
|
||||||
|
const sun = useMemo(
|
||||||
|
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
|
||||||
|
[lat, lon],
|
||||||
|
);
|
||||||
|
const sunBlock = sun ? (
|
||||||
|
<div className="ml-auto flex items-center gap-3 text-xs shrink-0"
|
||||||
|
title="Sunrise / sunset at the DX station (UTC)">
|
||||||
|
{sun.polarDay ? (
|
||||||
|
<span className="font-semibold text-warning">midnight sun</span>
|
||||||
|
) : sun.polarNight ? (
|
||||||
|
<span className="font-semibold text-info">polar night</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Sunrise className="size-3.5 text-warning" />
|
||||||
|
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Sunset className="size-3.5 text-info" />
|
||||||
|
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">UTC</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -158,7 +193,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
|
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-[220px]">
|
<div className="flex items-center gap-2 min-w-[220px] flex-1">
|
||||||
{newOne ? (
|
{newOne ? (
|
||||||
<>
|
<>
|
||||||
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
||||||
@@ -207,6 +242,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
Type a callsign to see entity stats
|
Type a callsign to see entity stats
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{sunBlock}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export type SpotStatusEntry = {
|
|||||||
worked_call?: boolean;
|
worked_call?: boolean;
|
||||||
new_county?: boolean;
|
new_county?: boolean;
|
||||||
new_pota?: boolean;
|
new_pota?: boolean;
|
||||||
|
new_pfx?: boolean;
|
||||||
|
pfx?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -99,47 +101,42 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// statusBadge maps a resolved spot status to a short labelled badge for the
|
// Spot status is shown by COLOURING THE TEXT, never by a pill or a badge.
|
||||||
// Status column, using the same colours as the per-cell fills (NEW DXCC =
|
//
|
||||||
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
|
// The pills were dropped: a rounded box has its own height and padding, so it
|
||||||
// there's nothing notable to show.
|
// sat off the row's baseline and the callsign inside it no longer lined up with
|
||||||
type Badge = { text: string; fg: string; bg: string; bd: string };
|
// the plain callsigns above and below. A whole column of them read as chrome
|
||||||
|
// rather than as data. Same reasoning — and the same semantic tokens — as the
|
||||||
|
// Y/N/R QSL columns in lib/qslStatus.ts.
|
||||||
|
//
|
||||||
|
// Which cell is coloured IS the message, so one colour is enough for all three:
|
||||||
|
//
|
||||||
|
// yellow call → new DXCC yellow band → new band yellow mode → new mode
|
||||||
|
// blue call → already worked
|
||||||
|
const NEW = 'var(--warning)'; // yellow: something here is new
|
||||||
|
const WKD = 'var(--info)'; // blue: this callsign is already in the log
|
||||||
|
|
||||||
// tok builds a badge from a semantic status token (success/warning/caution/
|
// cellText renders a cell value in an optional colour. An empty value keeps the
|
||||||
// danger/info/neutral) so the colours adapt to every theme via CSS variables,
|
// muted dash the grid used before, so blank cells still read as "nothing here"
|
||||||
// instead of the hard-coded light-theme pastels that looked wrong in dark mode.
|
// rather than as a gap.
|
||||||
function tok(name: string, text: string): Badge {
|
function cellText(value: any, color: string | null): any {
|
||||||
if (name === 'neutral') return { text, fg: 'var(--muted-foreground)', bg: 'var(--muted)', bd: 'var(--border)' };
|
|
||||||
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
|
|
||||||
}
|
|
||||||
|
|
||||||
// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the
|
|
||||||
// Status badges) when the slot is notable, instead of flooding the whole cell with
|
|
||||||
// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is
|
|
||||||
// null → plain text, no pill.
|
|
||||||
function cellChip(value: any, name: string | null): any {
|
|
||||||
const txt = value === undefined || value === null || value === '' ? '' : String(value);
|
const txt = value === undefined || value === null || value === '' ? '' : String(value);
|
||||||
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
if (!txt) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||||
// Inherit the column's font size (no fixed 9px / height) so a pill around a
|
return <span style={color ? { color, fontWeight: 700 } : undefined}>{txt}</span>;
|
||||||
// callsign stays the same size as the plain callsigns next to it — just tinted
|
|
||||||
// and rounded, not shrunk.
|
|
||||||
return (
|
|
||||||
<span style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', lineHeight: 1.1,
|
|
||||||
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
|
|
||||||
border: `1px solid var(--${name}-border)`, fontWeight: 700,
|
|
||||||
padding: '1px 6px', borderRadius: 999, whiteSpace: 'nowrap',
|
|
||||||
}}>{txt}</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
|
// statusColor is the colour for a resolved status in the Status column. County,
|
||||||
|
// POTA and prefix keep their own tokens: they are orthogonal to the band/mode/
|
||||||
|
// DXCC story and an operator filters on them separately.
|
||||||
|
function statusColor(s: SpotStatusEntry | undefined): string | null {
|
||||||
switch (s?.status) {
|
switch (s?.status) {
|
||||||
case 'new': return tok('danger', t('clg2.newDxcc'));
|
case 'new':
|
||||||
case 'new-band': return tok('warning', t('clg2.newBand'));
|
case 'new-band':
|
||||||
case 'new-mode': return tok('caution', t('clg2.newMode'));
|
case 'new-mode':
|
||||||
case 'new-slot': return tok('caution', t('clg2.newSlot'));
|
case 'new-slot':
|
||||||
default: return s?.worked_call ? tok('neutral', t('clg2.wkdCall')) : null;
|
return NEW;
|
||||||
|
default:
|
||||||
|
return s?.worked_call ? WKD : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,15 +163,12 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
// NEW DXCC → a danger pill around the call, consistent with the NEW BAND /
|
// NEW DXCC → yellow call. Already worked → blue call. Anything else keeps
|
||||||
// NEW MODE pills (same look, same tokens). Worked-call stays blue bold text;
|
// the theme's normal ink so ordinary callsigns don't shout.
|
||||||
// a plain spot inherits the theme's normal text colour so callsigns blend in
|
|
||||||
// with the rest of the row instead of always shouting a colour.
|
|
||||||
cellRenderer: (p: any) => {
|
cellRenderer: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
if (s?.status === 'new') return cellChip(p.value, 'danger');
|
const color = s?.status === 'new' ? NEW : s?.worked_call ? WKD : null;
|
||||||
const color = s?.worked_call ? 'var(--info)' : undefined;
|
return <span style={{ color: color ?? undefined, fontWeight: 700 }}>{p.value ?? ''}</span>;
|
||||||
return <span style={{ color, fontWeight: 700 }}>{p.value ?? ''}</span>;
|
|
||||||
},
|
},
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
@@ -185,9 +179,10 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
|
||||||
headerName: t('clg2.c.status'), width: 120, sortable: true,
|
headerName: t('clg2.c.status'), width: 120, sortable: true,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
// Spells out the slot status as a text badge so NEW SLOT (and the others)
|
// Spells the status out in words so NEW SLOT (and the others) is obvious at
|
||||||
// is obvious at the row level, not just a single coloured cell. NEW COUNTY
|
// the row level, not just a single coloured cell — NEW SLOT in particular
|
||||||
// and NEW POTA are orthogonal, so they stack as extra badges.
|
// colours no cell at all, since neither the band nor the mode is new on its
|
||||||
|
// own. NEW COUNTY and NEW POTA are orthogonal, so they stack after it.
|
||||||
valueGetter: (p: any) => {
|
valueGetter: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
@@ -198,25 +193,31 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
|
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
|
||||||
if (s?.new_county) parts.push(t('clg2.newCounty'));
|
if (s?.new_county) parts.push(t('clg2.newCounty'));
|
||||||
if (s?.new_pota) parts.push(t('clg2.newPota'));
|
if (s?.new_pota) parts.push(t('clg2.newPota'));
|
||||||
|
if (s?.new_pfx) parts.push(t('clg2.newPfx'));
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
},
|
},
|
||||||
cellRenderer: (p: any) => {
|
cellRenderer: (p: any) => {
|
||||||
const s = statusFor(p);
|
const s = statusFor(p);
|
||||||
const badges: Badge[] = [];
|
const parts: { text: string; color: string }[] = [];
|
||||||
const b = statusBadge(t, s);
|
const main = statusColor(s);
|
||||||
if (b) badges.push(b);
|
if (main) {
|
||||||
if (s?.new_county) badges.push(tok('info', t('clg2.newCounty')));
|
const label = s?.status === 'new' ? t('clg2.newDxcc')
|
||||||
if (s?.new_pota) badges.push(tok('success', t('clg2.newPota')));
|
: s?.status === 'new-band' ? t('clg2.newBand')
|
||||||
if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
: s?.status === 'new-mode' ? t('clg2.newMode')
|
||||||
|
: s?.status === 'new-slot' ? t('clg2.newSlot')
|
||||||
|
: t('clg2.wkdCall');
|
||||||
|
parts.push({ text: label, color: main });
|
||||||
|
}
|
||||||
|
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: 'var(--success)' });
|
||||||
|
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: 'var(--success)' });
|
||||||
|
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: 'var(--caution)' });
|
||||||
|
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
|
||||||
return (
|
return (
|
||||||
<span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
|
<span style={{ whiteSpace: 'nowrap' }}>
|
||||||
{badges.map((bd, i) => (
|
{parts.map((pt, i) => (
|
||||||
<span key={i} style={{
|
<span key={i} style={{ color: pt.color, fontWeight: 700 }}>
|
||||||
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
|
{i > 0 ? ' · ' : ''}{pt.text}
|
||||||
backgroundColor: bd.bg, color: bd.fg, border: `1px solid ${bd.bd}`,
|
</span>
|
||||||
fontWeight: 700, fontSize: 9,
|
|
||||||
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
|
|
||||||
}}>{bd.text}</span>
|
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -249,8 +250,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellClass: 'font-mono',
|
cellClass: 'font-mono',
|
||||||
// NEW BAND for this entity → small warning pill around the band text.
|
// NEW BAND for this entity → the band text turns yellow.
|
||||||
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null),
|
cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-band' ? NEW : null),
|
||||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -263,7 +264,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
// for the entity. NEW SLOT means band AND mode were each worked before (just
|
// for the entity. NEW SLOT means band AND mode were each worked before (just
|
||||||
// not together), so highlighting the mode cell would wrongly imply "CW is new";
|
// not together), so highlighting the mode cell would wrongly imply "CW is new";
|
||||||
// that case is signalled by the Status badge alone.
|
// that case is signalled by the Status badge alone.
|
||||||
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null),
|
cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-mode' ? NEW : null),
|
||||||
tooltipValueGetter: (p: any) => {
|
tooltipValueGetter: (p: any) => {
|
||||||
const st = statusFor(p)?.status;
|
const st = statusFor(p)?.status;
|
||||||
if (st === 'new-mode') return t('clg2.tipNewMode');
|
if (st === 'new-mode') return t('clg2.tipNewMode');
|
||||||
|
|||||||
@@ -215,6 +215,15 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [operatorGrid, remoteGrid, details.lat, details.lon]);
|
}, [operatorGrid, remoteGrid, details.lat, details.lon]);
|
||||||
|
// Where the DX actually is, for its sunrise/sunset. The locator wins when we
|
||||||
|
// have one — it is what the operator can see and check — and the provider's
|
||||||
|
// raw coordinates cover entities that resolve through cty.dat with no grid.
|
||||||
|
const dxLL = useMemo(() => {
|
||||||
|
const byGrid = gridToLatLon(remoteGrid);
|
||||||
|
if (byGrid) return byGrid;
|
||||||
|
if (details.lat != null && details.lon != null) return { lat: details.lat, lon: details.lon };
|
||||||
|
return null;
|
||||||
|
}, [remoteGrid, details.lat, details.lon]);
|
||||||
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
||||||
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
||||||
|
|
||||||
@@ -263,7 +272,8 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
|
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
|
||||||
{open === 'stats' && (
|
{open === 'stats' && (
|
||||||
<div className="px-3 py-2.5">
|
<div className="px-3 py-2.5">
|
||||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
|
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''}
|
||||||
|
lat={dxLL?.lat} lon={dxLL?.lon} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -234,7 +234,14 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
<SelectItem key={n} value={n}>
|
<SelectItem key={n} value={n}>
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
{n}
|
{n}
|
||||||
<Trash2 className="size-3 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); deletePreset(n); }} />
|
{/* Radix SelectItem selects on pointer-up, so an onClick here
|
||||||
|
fires too late — the menu has already loaded the preset and
|
||||||
|
closed. Intercept pointer-down (preventDefault stops Radix
|
||||||
|
claiming it) and delete there instead. */}
|
||||||
|
<Trash2
|
||||||
|
className="size-3 text-muted-foreground hover:text-destructive"
|
||||||
|
onPointerDown={(e) => { e.preventDefault(); e.stopPropagation(); deletePreset(n); }}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -323,13 +323,18 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [st.cw_speed]);
|
}, [st.cw_speed]);
|
||||||
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
// Peak-hold: keep the highest reading for a second so the jittery VITA-49
|
||||||
// read steadily instead of jumping every poll.
|
// meters read steadily instead of jumping every poll. One second, not two —
|
||||||
|
// longer and the meters visibly trail the end of a transmission, which reads
|
||||||
|
// as the app lagging rather than as a peak being held.
|
||||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||||
const peakHold = (key: string, val: number) => {
|
const peakHold = (key: string, val: number, windowMs = 1000, live = true) => {
|
||||||
|
// live=false throws the held value away at once (e.g. the carrier dropped),
|
||||||
|
// so the meter falls immediately instead of coasting for the window.
|
||||||
|
if (!live) { delete peak.current[key]; return val; }
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const p = peak.current[key];
|
const p = peak.current[key];
|
||||||
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
|
||||||
return p.v;
|
return p.v;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -355,12 +360,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// PowerGenius XL direct connection (fan mode), independent of the Flex link.
|
// PowerGenius XL direct connection (fan mode), independent of the Flex link.
|
||||||
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string }>({ connected: false });
|
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string; state?: string; peak_w?: number }>({ connected: false });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
|
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
|
||||||
tick();
|
tick();
|
||||||
const id = window.setInterval(tick, 2000);
|
const id = window.setInterval(tick, 250); // match the amp's 250 ms poll so the card catches every envelope peak
|
||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -978,10 +983,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
</button>
|
</button>
|
||||||
{/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */}
|
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
|
||||||
|
is off (no status = not "connected"), and serial only. */}
|
||||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||||
<button type="button" disabled={!spe.connected}
|
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
|
||||||
onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
|
onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
|
||||||
|
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
|
||||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||||
<button type="button" disabled={!spe.connected}
|
<button type="button" disabled={!spe.connected}
|
||||||
onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
|
onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
|
||||||
@@ -1083,9 +1090,6 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
st.amp_operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
st.amp_operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
|
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{st.amp_operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
|
|
||||||
</span>
|
|
||||||
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
||||||
PowerGenius). The dot shows the direct-connection state; the
|
PowerGenius). The dot shows the direct-connection state; the
|
||||||
selector is disabled until connected (hover it for the error). */}
|
selector is disabled until connected (hover it for the error). */}
|
||||||
@@ -1113,11 +1117,38 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
|
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
|
||||||
and shown compact so they sit with the amp controls. */}
|
and shown compact so they sit with the amp controls. */}
|
||||||
{(() => {
|
{(() => {
|
||||||
|
// Prefer the radio's VITA amp meters when they are flowing (local or
|
||||||
|
// over a VPN) — fast, and the same values SmartSDR shows (incl. the
|
||||||
|
// right temperature sensor). Fall back to the amp's OWN link only when
|
||||||
|
// the radio isn't streaming them (a remote link without VITA).
|
||||||
const meters = st.meters || [];
|
const meters = st.meters || [];
|
||||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||||
if (off || amp.length === 0) return null;
|
if (off || amp.length === 0) {
|
||||||
|
const P = pg as any;
|
||||||
|
if (!P.connected) return null;
|
||||||
|
// Direct-link fallback: a 500 ms poll samples the SSB envelope at
|
||||||
|
// random points, so peak-hold it (live=txing drops it the instant
|
||||||
|
// PTT releases). The LDMOS pair can pull well past 25 A → 50 A scale.
|
||||||
|
const txing = typeof st.transmitting === 'boolean' ? st.transmitting : /TRANSMIT/i.test(P.state || '');
|
||||||
|
const w = txing ? Math.round(Number(P.fwd_w) || 0) : 0;
|
||||||
|
const idA = txing ? Number(P.id) || 0 : 0;
|
||||||
|
const temp = Number(P.temperature) || 0;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
|
<MeterBar label={t('ampw.pwr')} value={peakHold('pgw', w, 2500, txing)} unit="W" lo={0} hi={2000} accent="#dc2626" />
|
||||||
|
<MeterBar label={t('ampw.id')} value={peakHold('pgid', idA, 2500, txing)} unit="A" lo={0} hi={50} accent="#16a34a" />
|
||||||
|
<MeterBar label={t('ampw.temp')} value={temp} unit="°C" lo={0} hi={80} accent="#ea580c" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Power comes from the radio's meter stream and nothing else. The
|
||||||
|
// amplifier also reports a "peakfwd", and using it was a mistake
|
||||||
|
// twice over: it is a latched maximum that is never reset, and it
|
||||||
|
// survives in the last-known status after the amp disconnects — so
|
||||||
|
// it claimed 1350 W from an old transmission while the radio was
|
||||||
|
// putting out 10.
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
{amp.map((m) => {
|
{amp.map((m) => {
|
||||||
@@ -1126,14 +1157,13 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
}
|
}
|
||||||
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||||
// Drain current (ID): the PGXL reports a full-scale far too small
|
// Drain current (ID): the PGXL reports a full-scale far too small
|
||||||
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
|
// for this meter, so it pinned the bar. Give it a fixed 50 A scale
|
||||||
// value itself is fine — only the bar's range was wrong. Give it a
|
// (the LDMOS pair can pull ~45-50 A near full output), overriding an
|
||||||
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
|
// unusable reported range but keeping a sane one.
|
||||||
// only override an unusable reported range, not a sane one.
|
|
||||||
let lo = m.lo, hi = m.hi;
|
let lo = m.lo, hi = m.hi;
|
||||||
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
||||||
lo = 0;
|
lo = 0;
|
||||||
hi = m.hi >= 25 ? m.hi : 25;
|
hi = m.hi >= 50 ? m.hi : 50;
|
||||||
}
|
}
|
||||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { TailLogFile, GetLogFilePath } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// LogViewer shows the tail of opslog.log and keeps it current while open.
|
||||||
|
//
|
||||||
|
// This exists because diagnosing a problem used to mean asking the operator to
|
||||||
|
// find a file in their data directory, open it in a text editor, and re-open it
|
||||||
|
// after every attempt. Half the round-trips in a bug report were spent on that.
|
||||||
|
//
|
||||||
|
// Three behaviours matter more than they look:
|
||||||
|
// • Auto-scroll is a checkbox the operator owns, AND it releases itself the
|
||||||
|
// moment they scroll up — otherwise the line they are reading is yanked away
|
||||||
|
// a second later, which defeats the one thing this window is for. Scrolling
|
||||||
|
// back to the bottom re-arms it, so following is never a dead end.
|
||||||
|
// • The filter keeps only matching LINES rather than highlighting inside a
|
||||||
|
// 256 KB blob: with "cluster" or "acom" typed in, the window becomes exactly
|
||||||
|
// the subsystem trace you would have grepped for.
|
||||||
|
// • It polls only while open. A closed dialog costs nothing.
|
||||||
|
const TAIL_BYTES = 256 * 1024;
|
||||||
|
const POLL_MS = 1000;
|
||||||
|
|
||||||
|
export function LogViewer({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [path, setPath] = useState('');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
|
// Shown next to the checkbox so a QUIET log still proves it is being polled —
|
||||||
|
// without it there is no way to tell 'nothing new' from 'window is dead'.
|
||||||
|
const [lastPull, setLastPull] = useState('');
|
||||||
|
const boxRef = useRef<HTMLPreElement>(null);
|
||||||
|
const autoRef = useRef(true);
|
||||||
|
useEffect(() => { autoRef.current = autoScroll; }, [autoScroll]);
|
||||||
|
|
||||||
|
// stick pins the view to the bottom on the NEXT frame. Setting scrollTop
|
||||||
|
// straight away is not enough while the dialog is still animating in: the box
|
||||||
|
// has no height yet, scrollHeight is wrong, and the scroll silently does
|
||||||
|
// nothing — leaving the operator staring at the top of a 256 KB buffer, i.e.
|
||||||
|
// at lines from hours ago, which looks exactly like a frozen window.
|
||||||
|
const stick = useCallback(() => {
|
||||||
|
if (!autoRef.current) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = boxRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pull = useCallback(() => {
|
||||||
|
TailLogFile(TAIL_BYTES).then((s: any) => {
|
||||||
|
setText(String(s ?? ''));
|
||||||
|
setLastPull(new Date().toLocaleTimeString());
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setAutoScroll(true);
|
||||||
|
autoRef.current = true;
|
||||||
|
GetLogFilePath().then((p: any) => setPath(String(p ?? ''))).catch(() => {});
|
||||||
|
pull();
|
||||||
|
// Re-assert the bottom after the open animation has settled. A quiet log
|
||||||
|
// never changes, so the content-driven scroll below would never fire again
|
||||||
|
// and the first attempt would be the only one.
|
||||||
|
const t1 = window.setTimeout(stick, 120);
|
||||||
|
const t2 = window.setTimeout(stick, 450);
|
||||||
|
const id = window.setInterval(pull, POLL_MS);
|
||||||
|
return () => { window.clearInterval(id); window.clearTimeout(t1); window.clearTimeout(t2); };
|
||||||
|
}, [open, pull, stick]);
|
||||||
|
|
||||||
|
const all = useMemo(() => text.split('\n'), [text]);
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return all;
|
||||||
|
return all.filter((l) => l.toLowerCase().includes(q));
|
||||||
|
}, [all, query]);
|
||||||
|
|
||||||
|
// Stick to the bottom after each refresh, but only while auto-scroll is on.
|
||||||
|
useEffect(() => { if (open) stick(); }, [shown, open, stick]);
|
||||||
|
|
||||||
|
// Scrolling away from the bottom releases auto-scroll; coming back re-arms it.
|
||||||
|
function onScroll() {
|
||||||
|
const el = boxRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
setAutoScroll(el.scrollHeight - el.scrollTop - el.clientHeight < 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = shown.join('\n');
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-5xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('logview.title')}</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono text-[11px] break-all">{path}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input className="h-8 flex-1 font-mono text-xs" value={query} placeholder={t('logview.searchPh')}
|
||||||
|
onChange={(e) => setQuery(e.target.value)} />
|
||||||
|
{query.trim() !== '' && (
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||||
|
{t('logview.matches', { n: shown.length, total: all.length })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre ref={boxRef} onScroll={onScroll}
|
||||||
|
className="h-[60vh] overflow-auto rounded-md border border-border bg-muted/30 p-2 text-[11px] leading-snug font-mono whitespace-pre-wrap break-all">
|
||||||
|
{body.trim() === '' ? t('logview.empty') : body}
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||||
|
<Checkbox checked={autoScroll} onCheckedChange={(c) => {
|
||||||
|
const on = !!c;
|
||||||
|
setAutoScroll(on);
|
||||||
|
if (on) { const el = boxRef.current; if (el) el.scrollTop = el.scrollHeight; }
|
||||||
|
}} />
|
||||||
|
{t('logview.autoScroll')}
|
||||||
|
</label>
|
||||||
|
{lastPull && <span className="text-xs text-muted-foreground tabular-nums">{t('logview.updated', { time: lastPull })}</span>}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigator.clipboard?.writeText(body)}>
|
||||||
|
{t('logview.copy')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => { setQuery(''); setAutoScroll(true); pull(); }}>
|
||||||
|
{t('logview.toEnd')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
@@ -120,6 +120,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
|
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
|
||||||
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
|
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
|
||||||
}, [service]);
|
}, [service]);
|
||||||
|
// Closing the QSL Manager (the tab's ×) unmounts this panel — cancel any download
|
||||||
|
// still running so a slow QRZ/LoTW sync doesn't keep going in the background and
|
||||||
|
// bleed its log into the next one.
|
||||||
|
useEffect(() => () => { CancelConfirmations().catch(() => {}); }, []);
|
||||||
const [potaSyncing, setPotaSyncing] = useState(false);
|
const [potaSyncing, setPotaSyncing] = useState(false);
|
||||||
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
||||||
const [potaErr, setPotaErr] = useState('');
|
const [potaErr, setPotaErr] = useState('');
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ interface Props {
|
|||||||
centerLat?: number | null; // operator latitude (projection centre)
|
centerLat?: number | null; // operator latitude (projection centre)
|
||||||
centerLon?: number | null; // operator longitude
|
centerLon?: number | null; // operator longitude
|
||||||
rotorEnabled?: boolean;
|
rotorEnabled?: boolean;
|
||||||
|
rotors?: string[]; // logical rotor names; >1 → show a selector
|
||||||
|
activeRotor?: number; // index of the selected rotor (0-based)
|
||||||
|
onSelectRotor?: (i: number) => void; // switch the active rotor
|
||||||
onGoto?: (az: number) => void; // click-to-turn
|
onGoto?: (az: number) => void; // click-to-turn
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
@@ -38,7 +41,7 @@ function pt(az: number, radius: number): [number, number] {
|
|||||||
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
|
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose }: Props) {
|
||||||
const cardinals = useMemo(
|
const cardinals = useMemo(
|
||||||
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
||||||
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
||||||
@@ -100,6 +103,31 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Multiple rotors — pick which one the dial shows and turns. */}
|
||||||
|
{rotors && rotors.length > 1 && (
|
||||||
|
<div className="flex flex-wrap gap-1 px-2 pt-1.5">
|
||||||
|
{rotors.map((nm, i) => {
|
||||||
|
const active = (activeRotor ?? 0) === i;
|
||||||
|
const label = nm?.trim() || `Rotor ${i + 1}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onSelectRotor?.(i)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 min-w-[48px] px-1.5 py-0.5 rounded text-[10px] font-semibold truncate transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-success text-success-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground hover:bg-muted/70',
|
||||||
|
)}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-2 min-h-0">
|
<div className="flex items-center justify-center p-2 min-h-0">
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
GetListsSettings, SaveListsSettings,
|
GetListsSettings, SaveListsSettings,
|
||||||
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||||
@@ -80,7 +80,7 @@ type StationSettings = StationSettingsForm;
|
|||||||
type ListsSettings = ListsSettingsForm;
|
type ListsSettings = ListsSettingsForm;
|
||||||
type ModePreset = ModePresetForm;
|
type ModePreset = ModePresetForm;
|
||||||
type CATSettings = Omit<mainModels.CATSettings, 'convertValues'>;
|
type CATSettings = Omit<mainModels.CATSettings, 'convertValues'>;
|
||||||
type RotatorSettings = Omit<mainModels.RotatorSettings, 'convertValues'>;
|
type RotatorDevice = Omit<mainModels.RotatorDevice, 'convertValues'>;
|
||||||
type ClusterServer = Omit<clusterModels.ServerConfig, 'convertValues'>;
|
type ClusterServer = Omit<clusterModels.ServerConfig, 'convertValues'>;
|
||||||
type ClusterServerStatus = Omit<clusterModels.ServerStatus, 'convertValues'>;
|
type ClusterServerStatus = Omit<clusterModels.ServerStatus, 'convertValues'>;
|
||||||
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
||||||
@@ -1136,14 +1136,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [modeDraft, setModeDraft] = useState('');
|
const [modeDraft, setModeDraft] = useState('');
|
||||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
||||||
yaesu_port: '', yaesu_baud: 38400, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
|
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
|
||||||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
||||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
||||||
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
||||||
|
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
|
||||||
});
|
});
|
||||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
// While true, the next key press is captured as the PTT hotkey.
|
||||||
enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1,
|
const [capturingPtt, setCapturingPtt] = useState(false);
|
||||||
} as any);
|
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
|
||||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
@@ -1498,7 +1499,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
try {
|
try {
|
||||||
const [l, ls, c, ap, r, b, qd, es] = await Promise.all([
|
const [l, ls, c, ap, r, b, qd, es] = await Promise.all([
|
||||||
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
|
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
|
||||||
GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(),
|
GetRotators(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(),
|
||||||
]);
|
]);
|
||||||
setLookup(l);
|
setLookup(l);
|
||||||
setActiveProfile(ap as Profile);
|
setActiveProfile(ap as Profile);
|
||||||
@@ -1511,7 +1512,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
await reloadProfiles();
|
await reloadProfiles();
|
||||||
await reloadClusterServers();
|
await reloadClusterServers();
|
||||||
setCatCfg(c);
|
setCatCfg(c);
|
||||||
setRotator(r);
|
setRotors((r ?? []) as any);
|
||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||||
@@ -1552,7 +1553,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
try { setActiveProfile(await GetActiveProfile() as Profile); } catch {}
|
try { setActiveProfile(await GetActiveProfile() as Profile); } catch {}
|
||||||
try { setLookup(await GetLookupSettings() as any); } catch {}
|
try { setLookup(await GetLookupSettings() as any); } catch {}
|
||||||
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
||||||
try { setRotator(await GetRotatorSettings() as any); } catch {}
|
try { setRotors(((await GetRotators()) ?? []) as any); } catch {}
|
||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||||
@@ -1744,7 +1745,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
await SaveLookupSettings(lookup as any);
|
await SaveLookupSettings(lookup as any);
|
||||||
await SaveCATSettings(catCfg as any);
|
await SaveCATSettings(catCfg as any);
|
||||||
await SaveRotatorSettings(rotator as any);
|
await SaveRotators(rotors as any);
|
||||||
await SaveUltrabeamSettings(ultrabeam as any);
|
await SaveUltrabeamSettings(ultrabeam as any);
|
||||||
await SaveAntGeniusSettings(antgenius as any);
|
await SaveAntGeniusSettings(antgenius as any);
|
||||||
await SaveTunerGeniusSettings(tunergenius as any);
|
await SaveTunerGeniusSettings(tunergenius as any);
|
||||||
@@ -2580,6 +2581,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</Select>
|
</Select>
|
||||||
<span className="text-xs text-muted-foreground">{t('cat.yaesuBaudHint')}</span>
|
<span className="text-xs text-muted-foreground">{t('cat.yaesuBaudHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||||
|
<Checkbox checked={!!catCfg.yaesu_low_lines} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, yaesu_low_lines: !!c }))} />
|
||||||
|
{t('cat.lowerLines')}
|
||||||
|
</label>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{['icom', 'xiegu', 'kenwood'].includes(catCfg.backend) && (
|
{['icom', 'xiegu', 'kenwood'].includes(catCfg.backend) && (
|
||||||
@@ -2627,6 +2635,25 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">{t('cat.kenwoodHostHint')}</span>
|
<span className="text-xs text-muted-foreground">{t('cat.kenwoodHostHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||||
|
<Checkbox checked={!!catCfg.kenwood_low_lines} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, kenwood_low_lines: !!c }))} />
|
||||||
|
{t('cat.lowerLines')}
|
||||||
|
</label>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.kwDataMode')}</Label>
|
||||||
|
<Select value={(catCfg as any).kenwood_data_mode || 'usb'} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_data_mode: v } as any))}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="usb">{t('cat.kwDataUsb')}</SelectItem>
|
||||||
|
<SelectItem value="data">{t('cat.kwDataMd6')}</SelectItem>
|
||||||
|
<SelectItem value="keep">{t('cat.kwDataKeep')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('cat.kwDataHint')}</span>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{catCfg.backend === 'icom' && (
|
{catCfg.backend === 'icom' && (
|
||||||
@@ -2797,6 +2824,54 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* PTT hotkey — a keyboard key that keys the rig while OpsLog is focused.
|
||||||
|
Uses the Audio → PTT method (CAT / RTS / DTR), falling back to CAT. */}
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={!!(catCfg as any).ptt_hotkey_enabled}
|
||||||
|
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, ptt_hotkey_enabled: !!c } as any))}
|
||||||
|
/>
|
||||||
|
{t('cat.pttKey')}
|
||||||
|
</label>
|
||||||
|
{(catCfg as any).ptt_hotkey_enabled && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (!capturingPtt) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.code === 'Escape') { setCapturingPtt(false); return; }
|
||||||
|
setCatCfg((s) => ({ ...s, ptt_hotkey: e.code } as any));
|
||||||
|
setCapturingPtt(false);
|
||||||
|
}}
|
||||||
|
onClick={() => setCapturingPtt(true)}
|
||||||
|
className="min-w-[140px] rounded-md border border-border bg-muted/40 px-3 py-1.5 text-sm font-mono hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
{capturingPtt ? t('cat.pttKeyPress') : ((catCfg as any).ptt_hotkey || t('cat.pttKeyNone'))}
|
||||||
|
</button>
|
||||||
|
{(catCfg as any).ptt_hotkey && !capturingPtt && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCatCfg((s) => ({ ...s, ptt_hotkey: '' } as any))}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t('cat.pttKeyClear')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={!!(catCfg as any).ptt_hotkey_toggle}
|
||||||
|
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, ptt_hotkey_toggle: !!c } as any))}
|
||||||
|
/>
|
||||||
|
{t('cat.pttKeyToggle')}
|
||||||
|
</label>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('cat.pttKeyHint')}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{catCfg.backend === 'omnirig' && (
|
{catCfg.backend === 'omnirig' && (
|
||||||
<>
|
<>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -2816,12 +2891,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testRotator() {
|
async function testRotator(dev: RotatorDevice, sub = 1) {
|
||||||
setRotatorTesting(true);
|
setRotatorTesting(true);
|
||||||
setRotatorTest(null);
|
setRotatorTest(null);
|
||||||
try {
|
try {
|
||||||
await TestRotator(rotator as any);
|
await TestRotatorDevice(dev as any, sub);
|
||||||
setRotatorTest({ ok: true, msg: (rotator as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
|
setRotatorTest({ ok: true, msg: (dev as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -3210,6 +3285,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* PowerGenius XL asks for a remote-access code when reached from
|
||||||
|
outside the LAN (banner "V… AUTH"), like the Tuner/Antenna Genius.
|
||||||
|
Blank on the LAN. */}
|
||||||
|
{isPGXL && (
|
||||||
|
<div className="space-y-1 max-w-xs">
|
||||||
|
<Label>{t('amp.password')}</Label>
|
||||||
|
<Input type="password" value={(amp as any).password ?? ''}
|
||||||
|
onChange={(e) => patchAmp(i, { password: e.target.value } as any)}
|
||||||
|
placeholder={t('amp.passwordPh')} className="font-mono" />
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('amp.passwordHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Band-follow, for any amp that takes its band from a transceiver
|
{/* Band-follow, for any amp that takes its band from a transceiver
|
||||||
CAT link (ACOM and SPE both do) — never PowerGenius, which is
|
CAT link (ACOM and SPE both do) — never PowerGenius, which is
|
||||||
@@ -3284,136 +3371,202 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Button variant="outline" size="sm" onClick={addAmp}>
|
<Button variant="outline" size="sm" onClick={addAmp}>
|
||||||
<Plus className="size-3.5 mr-1" /> {t('amp.add')}
|
<Plus className="size-3.5 mr-1" /> {t('amp.add')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RotatorPanel() {
|
function RotatorPanel() {
|
||||||
const isRG = (rotator as any).type === 'rotgenius';
|
// Rotors are a list, like amplifiers: add with +, remove with the trash icon.
|
||||||
const isARCO = (rotator as any).type === 'arco';
|
// A Rotator Genius with "two rotors" contributes 2 addressable rotors.
|
||||||
|
const patch = (i: number, p: Partial<RotatorDevice>) => setRotors((l) => l.map((d, j) => (j === i ? { ...d, ...p } : d)) as any);
|
||||||
|
const addRotor = () => setRotors((l) => [...l, {
|
||||||
|
id: '', name: '', type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false,
|
||||||
|
rotator_num: 1, dual: false, motorized: true, name2: '', motorized2: false,
|
||||||
|
transport: 'tcp', com_port: '', baud: 9600,
|
||||||
|
} as any]);
|
||||||
|
const removeRotor = (i: number) => setRotors((l) => l.filter((_, j) => j !== i));
|
||||||
|
const anyPst = rotors.some((d) => ((d as any).type ?? 'pst') === 'pst');
|
||||||
|
// The motorized-antenna choice only matters once more than one rotor exists.
|
||||||
|
const multi = rotors.length > 1 || rotors.some((d) => (d as any).type === 'rotgenius' && (d as any).dual);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader title="Rotator" hint={anyPst ? t('rot.hint') : undefined} />
|
||||||
title="Rotator"
|
|
||||||
hint={isRG || isARCO ? undefined : t('rot.hint')}
|
|
||||||
/>
|
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
{rotors.length === 0 && <p className="text-sm text-muted-foreground">{t('rot.none')}</p>}
|
||||||
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
|
{rotors.map((d, i) => {
|
||||||
{t('rot.enable')}
|
const dev = d as any;
|
||||||
</label>
|
const isRG = dev.type === 'rotgenius';
|
||||||
<div className="grid grid-cols-2 gap-3">
|
const isARCO = dev.type === 'arco';
|
||||||
<div className="space-y-1">
|
const transport = dev.transport ?? 'tcp';
|
||||||
<Label>{t('rot.type')}</Label>
|
return (
|
||||||
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
|
<div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3">
|
||||||
(placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
|
<div className="flex items-center gap-2">
|
||||||
<Select value={(rotator as any).type ?? 'pst'}
|
<Compass className="size-4 text-primary shrink-0" />
|
||||||
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 } as any))}>
|
<Input className="h-8 flex-1" value={dev.name ?? ''} placeholder={`Rotor ${i + 1}`}
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
onChange={(e) => patch(i, { name: e.target.value })} />
|
||||||
<SelectContent>
|
<Button size="icon" variant="ghost" className="size-8 text-muted-foreground hover:text-destructive"
|
||||||
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
onClick={() => removeRotor(i)} title={t('rot.remove')}>
|
||||||
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
<Trash2 className="size-4" />
|
||||||
<SelectItem value="arco">GS-232A controller (microHAM ARCO, ERC…)</SelectItem>
|
</Button>
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
</div>
|
<div className="space-y-1">
|
||||||
{isRG && (
|
<Label>{t('rot.type')}</Label>
|
||||||
<div className="space-y-1">
|
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
|
||||||
<Label>{t('rot.rotatorNum')}</Label>
|
(placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
|
||||||
<Select value={String((rotator as any).rotator_num ?? 1)}
|
<Select value={dev.type ?? 'pst'}
|
||||||
onValueChange={(v) => setRotator((s) => ({ ...s, rotator_num: parseInt(v, 10) || 1 } as any))}>
|
onValueChange={(v) => patch(i, { type: v as any, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 })}>
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="1">1</SelectItem>
|
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
||||||
<SelectItem value="2">2</SelectItem>
|
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
||||||
</SelectContent>
|
<SelectItem value="arco">GS-232A controller (microHAM ARCO, ERC…)</SelectItem>
|
||||||
</Select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{isRG && !dev.dual && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('rot.rotatorNum')}</Label>
|
||||||
|
<Select value={String(dev.rotator_num ?? 1)}
|
||||||
|
onValueChange={(v) => patch(i, { rotator_num: parseInt(v, 10) || 1 })}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1</SelectItem>
|
||||||
|
<SelectItem value="2">2</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* The ARCO is reachable over the LAN (TCP) or its USB virtual COM. */}
|
||||||
|
{isARCO && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Connection</Label>
|
||||||
|
<Select value={transport} onValueChange={(v) => patch(i, { transport: v as any })}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="tcp">Network (LAN)</SelectItem>
|
||||||
|
<SelectItem value="serial">USB (serial COM)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Rotator Genius drives both its ports — one entry = two rotors. */}
|
||||||
|
{isRG && (
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={!!dev.dual} onCheckedChange={(c) => patch(i, { dual: !!c })} />
|
||||||
|
{t('rot.rgDual')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{isARCO && transport === 'serial' ? (
|
||||||
|
<div className="space-y-1 max-w-xs">
|
||||||
|
<Label>COM port</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={dev.com_port || '_'} onValueChange={(v) => patch(i, { com_port: v === '_' ? '' : v })}>
|
||||||
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||||
|
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
|
</Button>
|
||||||
|
<Select value={String(dev.baud || 9600)} onValueChange={(v) => patch(i, { baud: Number(v) })}>
|
||||||
|
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[4800, 9600, 19200, 38400, 57600].map((b) => (
|
||||||
|
<SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Host / IP</Label>
|
||||||
|
<Input value={dev.host ?? ''} onChange={(e) => patch(i, { host: e.target.value })}
|
||||||
|
placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
|
||||||
|
<PortInput value={dev.port} onChange={(n) => patch(i, { port: n })}
|
||||||
|
fallback={isRG ? 9006 : isARCO ? 4001 : 12000} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isRG && !isARCO && (
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={!!dev.has_elevation} onCheckedChange={(c) => patch(i, { has_elevation: !!c })} />
|
||||||
|
This rotator supports elevation (VHF / satellite)
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
||||||
|
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
||||||
|
{/* Which antenna this rotor carries — only relevant with >1 rotor. */}
|
||||||
|
{multi && (
|
||||||
|
<div className="space-y-1 max-w-xs">
|
||||||
|
<Label>{isRG && dev.dual ? t('rot.antenna1') : t('rot.antenna')}</Label>
|
||||||
|
<Select value={dev.motorized ? 'motor' : 'std'} onValueChange={(v) => patch(i, { motorized: v === 'motor' })}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="std">{t('rot.antStd')}</SelectItem>
|
||||||
|
<SelectItem value="motor">{t('rot.antMotor')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Second logical rotor of a dual Rotator Genius: its own name + antenna. */}
|
||||||
|
{isRG && dev.dual && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('rot.name2')}</Label>
|
||||||
|
<Input value={dev.name2 ?? ''} placeholder={`${dev.name || 'Rotor'} 2`}
|
||||||
|
onChange={(e) => patch(i, { name2: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('rot.antenna2')}</Label>
|
||||||
|
<Select value={dev.motorized2 ? 'motor' : 'std'} onValueChange={(v) => patch(i, { motorized2: v === 'motor' })}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="std">{t('rot.antStd')}</SelectItem>
|
||||||
|
<SelectItem value="motor">{t('rot.antMotor')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => testRotator(d, 1)} disabled={rotatorTesting}>
|
||||||
|
{rotatorTesting ? t('hw.sending') : (isRG && dev.dual ? t('rot.testRotor1') : t('hw.testRotator'))}
|
||||||
|
</Button>
|
||||||
|
{isRG && dev.dual && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => testRotator(d, 2)} disabled={rotatorTesting}>
|
||||||
|
{t('rot.testRotor2')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
{/* The ARCO is reachable over the LAN (TCP) or its USB virtual COM. */}
|
})}
|
||||||
{isARCO && (
|
<Button variant="outline" size="sm" onClick={addRotor}>
|
||||||
<div className="space-y-1">
|
<Plus className="size-3.5 mr-1" /> {t('rot.add')}
|
||||||
<Label>Connection</Label>
|
</Button>
|
||||||
<Select value={(rotator as any).transport ?? 'tcp'}
|
{rotors.length > 0 && (
|
||||||
onValueChange={(v) => setRotator((s) => ({ ...s, transport: v } as any))}>
|
<div className="flex items-center gap-2 pt-2">
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))}>
|
||||||
<SelectContent>
|
Stop
|
||||||
<SelectItem value="tcp">Network (LAN)</SelectItem>
|
|
||||||
<SelectItem value="serial">USB (serial COM)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isARCO && (rotator as any).transport === 'serial' ? (
|
|
||||||
<div className="space-y-1 max-w-xs">
|
|
||||||
<Label>COM port</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Select value={(rotator as any).com_port || '_'}
|
|
||||||
onValueChange={(v) => setRotator((s) => ({ ...s, com_port: v === '_' ? '' : v } as any))}>
|
|
||||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
|
||||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
|
||||||
<ArrowDown className="size-3.5 rotate-90" />
|
|
||||||
</Button>
|
|
||||||
<Select value={String((rotator as any).baud || 9600)}
|
|
||||||
onValueChange={(v) => setRotator((s) => ({ ...s, baud: Number(v) } as any))}>
|
|
||||||
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{[4800, 9600, 19200, 38400, 57600].map((b) => (
|
|
||||||
<SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<div className="space-y-1 col-span-2">
|
|
||||||
<Label>Host / IP</Label>
|
|
||||||
<Input
|
|
||||||
value={rotator.host ?? ''}
|
|
||||||
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
|
|
||||||
placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'}
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
|
|
||||||
<PortInput
|
|
||||||
value={rotator.port}
|
|
||||||
onChange={(n) => setRotator((s) => ({ ...s, port: n }))} fallback={isRG ? 9006 : isARCO ? 4001 : 12000}
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isRG && !isARCO && (
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
||||||
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
|
||||||
This rotator supports elevation (VHF / satellite)
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
|
||||||
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
|
||||||
<div className="flex items-center gap-2 pt-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
|
||||||
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
|
||||||
Stop
|
|
||||||
</Button>
|
|
||||||
{!isRG && (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
|
||||||
Park
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
{anyPst && (
|
||||||
</div>
|
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))}>
|
||||||
|
Park
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{rotatorTest && (
|
{rotatorTest && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'text-xs rounded-md p-2.5 border',
|
'text-xs rounded-md p-2.5 border',
|
||||||
@@ -3425,8 +3578,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
From the main entry strip, click the bearing pill to rotate to the short-path azimuth.
|
From the main entry strip, click the bearing pill to rotate the active rotor to the short-path
|
||||||
Shift+click for long-path, Ctrl+click to stop.
|
azimuth. Shift+click for long-path, Ctrl+click to stop. With several rotors, pick the active one
|
||||||
|
on the compass widget.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -3464,6 +3618,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<SelectItem value="serial">{t('wk.engSerial')}</SelectItem>
|
<SelectItem value="serial">{t('wk.engSerial')}</SelectItem>
|
||||||
<SelectItem value="icom">{t('wk.engIcom')}</SelectItem>
|
<SelectItem value="icom">{t('wk.engIcom')}</SelectItem>
|
||||||
<SelectItem value="yaesu">{t('wk.engYaesu')}</SelectItem>
|
<SelectItem value="yaesu">{t('wk.engYaesu')}</SelectItem>
|
||||||
|
<SelectItem value="kenwood">{t('wk.engKenwood')}</SelectItem>
|
||||||
<SelectItem value="flex">{t('wk.engFlex')}</SelectItem>
|
<SelectItem value="flex">{t('wk.engFlex')}</SelectItem>
|
||||||
<SelectItem value="tci" disabled>{t('wk.engTci')}</SelectItem>
|
<SelectItem value="tci" disabled>{t('wk.engTci')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -3510,6 +3665,22 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : wk.engine === 'kenwood' ? (
|
||||||
|
<>
|
||||||
|
{(!catCfg.enabled || catCfg.backend !== 'kenwood') && (
|
||||||
|
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||||||
|
<span aria-hidden>⚠</span>
|
||||||
|
<span>{t('wk.catWarnKenwood', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground -mt-1">{t('wk.kenwoodHint')}</p>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('wk.speed')}</Label>
|
||||||
|
<Input type="number" min={4} max={60} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : wk.engine === 'flex' ? (
|
) : wk.engine === 'flex' ? (
|
||||||
<>
|
<>
|
||||||
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -13,7 +13,7 @@ import { TunerCard } from '@/components/TunerCard';
|
|||||||
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||||
import {
|
import {
|
||||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
GetRotatorHeading, RotatorGoTo, RotatorStop, SetActiveRotor,
|
||||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||||
GetAmpStatuses, GetFlexState,
|
GetAmpStatuses, GetFlexState,
|
||||||
@@ -26,19 +26,28 @@ type Device = { id: string; type: string; name: string; host: string; user?: str
|
|||||||
type Relay = { number: number; label: string; on: boolean };
|
type Relay = { number: number; label: string; on: boolean };
|
||||||
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||||||
|
|
||||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 };
|
// Dashboard geometry: a grid of EQUAL columns whose cards also share a common
|
||||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };
|
// HEIGHT per row — every card stretches to the tallest one beside it. Masonry
|
||||||
|
// packed tighter but left the row edges ragged, which read as untidy on a wide
|
||||||
|
// screen; aligned rows are what an operator expects from a dashboard.
|
||||||
|
const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column
|
||||||
|
const GRID_GAP = 16; // px between cards, both axes
|
||||||
|
|
||||||
|
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8, dingtian: 2 };
|
||||||
|
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)', dingtian: 'Dingtian IOT relay' };
|
||||||
|
|
||||||
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
|
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
|
||||||
// the generic USB-serial board, whose channel count the user picks.
|
// the generic USB-serial board, whose channel count the user picks.
|
||||||
const chanCount = (d: Device): number =>
|
const chanCount = (d: Device): number =>
|
||||||
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : (RELAY_COUNT[d.type] ?? 5);
|
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8)
|
||||||
|
: d.type === 'dingtian' ? (d.channels && d.channels >= 1 ? d.channels : 2)
|
||||||
|
: (RELAY_COUNT[d.type] ?? 5);
|
||||||
|
|
||||||
function blankDevice(): Device {
|
function blankDevice(): Device {
|
||||||
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||||||
}
|
}
|
||||||
|
|
||||||
type Heading = { enabled: boolean; ok: boolean; azimuth: number };
|
type Heading = { enabled: boolean; ok: boolean; azimuth: number; rotors?: string[]; active?: number };
|
||||||
|
|
||||||
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
|
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
|
||||||
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
||||||
@@ -73,6 +82,9 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
|||||||
centerLat={centerLat ?? null}
|
centerLat={centerLat ?? null}
|
||||||
centerLon={centerLon ?? null}
|
centerLon={centerLon ?? null}
|
||||||
rotorEnabled={hd.ok}
|
rotorEnabled={hd.ok}
|
||||||
|
rotors={hd.rotors}
|
||||||
|
activeRotor={hd.active}
|
||||||
|
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
||||||
onGoto={(az) => turn(az)}
|
onGoto={(az) => turn(az)}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0 space-y-2">
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
@@ -269,6 +281,23 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
// Max columns per row (persisted). "Auto" fills the window; a number caps the
|
// Max columns per row (persisted). "Auto" fills the window; a number caps the
|
||||||
// row so cards wrap onto further lines even when there's horizontal room.
|
// row so cards wrap onto further lines even when there's horizontal room.
|
||||||
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
||||||
|
|
||||||
|
// How many columns the dashboard shows. "Auto" fits as many CARD_MIN-wide
|
||||||
|
// cards as the window allows; an explicit count is honoured as asked, even
|
||||||
|
// past that width — the operator can see his own screen.
|
||||||
|
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [colCount, setColCount] = useState(1);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (cols !== 'auto') { setColCount(Number(cols) || 1); return; }
|
||||||
|
const el = gridRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => setColCount(Math.max(1, Math.floor((el.clientWidth + GRID_GAP) / (CARD_MIN + GRID_GAP))));
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [cols]);
|
||||||
// Amplifiers (Settings → Amplifier): shown here so operators without a
|
// Amplifiers (Settings → Amplifier): shown here so operators without a
|
||||||
// FlexRadio panel still get the controls. EVERY configured amp gets its own
|
// FlexRadio panel still get the controls. EVERY configured amp gets its own
|
||||||
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
|
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
|
||||||
@@ -415,10 +444,10 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// `wide` cards span the whole dashboard instead of sitting in one masonry
|
// `wide` cards take two grid columns instead of one. The amplifier and tuner
|
||||||
// column. The amplifier and tuner carry meter rows and a channel selector that
|
// carry meter rows and a channel selector that are unreadable squeezed into a
|
||||||
// are unreadable squeezed into a ~430px column — they are the same cards the
|
// single ~430px column — they are the same cards the FlexRadio panel shows
|
||||||
// FlexRadio panel shows full-width, and they need that room here too.
|
// full-width, and they need that room here too.
|
||||||
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
|
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
|
||||||
if (rot.enabled) {
|
if (rot.enabled) {
|
||||||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||||
@@ -446,7 +475,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
|
{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
|
||||||
card can sit on a further line even with horizontal room to spare. */}
|
card can sit on a further line even with horizontal room to spare. */}
|
||||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
||||||
{(['auto', '1', '2', '3', '4'] as const).map((c) => (
|
{(['auto', '1', '2', '3', '4', '5', '6'] as const).map((c) => (
|
||||||
<button key={c} type="button"
|
<button key={c} type="button"
|
||||||
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
||||||
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||||
@@ -466,31 +495,36 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Masonry dashboard: fixed-width cards flow into balanced CSS columns so
|
{/* Dashboard grid: equal columns, and every card stretches to the height of
|
||||||
they pack tightly by height (no ragged gaps under short cards). "Auto"
|
the tallest one on its row, so the rows line up instead of ending
|
||||||
fits as many ~430px columns as the window allows; a fixed count caps the
|
ragged. One column each, two for the amplifier/tuner whose meter rows
|
||||||
container width to that many columns. Each card has a grip handle (left
|
need the room. Each card has a grip handle (left rail) as the drag
|
||||||
rail) as the drag initiator (the body is full of buttons). */}
|
initiator, since the body is full of buttons. */}
|
||||||
<div style={{ columnWidth: '430px', columnGap: '1rem', columnFill: 'balance',
|
<div ref={gridRef} style={{
|
||||||
...(cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : {}) }}>
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
|
||||||
|
gap: `${GRID_GAP}px`,
|
||||||
|
}}>
|
||||||
{ordered.map((w) => (
|
{ordered.map((w) => (
|
||||||
// column-span:all lifts a wide card out of the columns and across the
|
<div key={w.id}
|
||||||
// full container, while keeping it in document order — so drag-reorder
|
// A wide card takes two columns — but never more than there are, or
|
||||||
// still works between wide and normal cards. Capped so it stays a card
|
// the grid would grow a phantom column on a narrow window.
|
||||||
// and not a banner on an ultra-wide window.
|
style={{ gridColumn: w.wide ? `span ${Math.min(2, colCount)}` : undefined }}
|
||||||
<div key={w.id} className="flex items-stretch break-inside-avoid mb-4"
|
|
||||||
style={w.wide ? { columnSpan: 'all', maxWidth: '900px' } : undefined}
|
|
||||||
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
|
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
|
||||||
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
|
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
|
||||||
<div draggable
|
{/* h-full down the chain is what makes the card fill the row height
|
||||||
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
rather than sit at its natural size in a taller cell. */}
|
||||||
onDragEnd={() => { dragId.current = null; }}
|
<div className="flex items-stretch h-full">
|
||||||
title={t('station.dragMove')}
|
<div draggable
|
||||||
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
|
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||||
dragId.current === w.id && 'opacity-60')}>
|
onDragEnd={() => { dragId.current = null; }}
|
||||||
<GripVertical className="size-4" />
|
title={t('station.dragMove')}
|
||||||
|
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
|
||||||
|
dragId.current === w.id && 'opacity-60')}>
|
||||||
|
<GripVertical className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 [&>*]:h-full">{w.node}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">{w.node}</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -523,6 +557,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
onChange({ ...device, channels, labels });
|
onChange({ ...device, channels, labels });
|
||||||
};
|
};
|
||||||
const isKM = device.type === 'kmtronic';
|
const isKM = device.type === 'kmtronic';
|
||||||
|
const isDingtian = device.type === 'dingtian';
|
||||||
const isDenkovi = device.type === 'denkovi';
|
const isDenkovi = device.type === 'denkovi';
|
||||||
const isUsbRelay = device.type === 'usbrelay';
|
const isUsbRelay = device.type === 'usbrelay';
|
||||||
// COM ports for the generic USB-serial relay picker.
|
// COM ports for the generic USB-serial relay picker.
|
||||||
@@ -573,6 +608,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
||||||
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
||||||
|
<SelectItem value="dingtian">Dingtian IOT relay (LAN / WiFi)</SelectItem>
|
||||||
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
|
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
|
||||||
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
|
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -583,13 +619,13 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(isDenkovi || isUsbRelay) && (
|
{(isDenkovi || isUsbRelay || isDingtian) && (
|
||||||
<div className="space-y-1 max-w-[10rem]">
|
<div className="space-y-1 max-w-[10rem]">
|
||||||
<Label>{t('station.channels')}</Label>
|
<Label>{t('station.channels')}</Label>
|
||||||
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
|
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(isDenkovi ? [4, 8] : [1, 2, 4, 8, 16]).map((n) => (
|
{(isDenkovi ? [4, 8] : isDingtian ? [2, 4, 8, 16, 24, 32] : [1, 2, 4, 8, 16]).map((n) => (
|
||||||
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -632,11 +668,28 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
|
<div className={cn('grid gap-3', (isKM || isDingtian) ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||||
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
|
<div className={cn('space-y-1', (isKM || isDingtian) ? '' : 'max-w-xs')}>
|
||||||
<Label>{t('station.host')}</Label>
|
<Label>{t('station.host')}</Label>
|
||||||
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
|
{/* Dingtian: both are OFF on a factory board — the session ID only when
|
||||||
|
"HTTP Session" is enabled in its web page, the password only when a
|
||||||
|
relay password is set. */}
|
||||||
|
{isDingtian && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.dtSession')}</Label>
|
||||||
|
<Input className="font-mono" value={device.user ?? ''} placeholder={t('station.optional')}
|
||||||
|
onChange={(e) => onChange({ ...device, user: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.dtPwd')}</Label>
|
||||||
|
<Input className="font-mono" value={device.pass ?? ''} placeholder="0"
|
||||||
|
onChange={(e) => onChange({ ...device, pass: e.target.value.replace(/[^0-9]/g, '') })} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{isKM && (
|
{isKM && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@@ -76,13 +76,16 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
|
|||||||
const peakHold = (key: string, val: number) => {
|
const peakHold = (key: string, val: number) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const p = peak.current[key];
|
const p = peak.current[key];
|
||||||
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
if (!p || val >= p.v || now - p.t > 1000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||||
return p.v;
|
return p.v;
|
||||||
};
|
};
|
||||||
|
|
||||||
const connected = !!status.connected;
|
const connected = !!status.connected;
|
||||||
const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||||
const rawFwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0;
|
// Prefer the device's own peak detector over the sampled reading; the JS hold
|
||||||
|
// below still smooths what is left between polls.
|
||||||
|
const rawPeak = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
|
||||||
|
const rawFwdW = rawPeak >= 1 ? rawPeak : 0;
|
||||||
const fwdW = peakHold('fwd', rawFwdW);
|
const fwdW = peakHold('fwd', rawFwdW);
|
||||||
|
|
||||||
// SWR needs RF to mean anything: sampled on receive the device reports a
|
// SWR needs RF to mean anything: sampled on receive the device reports a
|
||||||
@@ -93,13 +96,14 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
|
|||||||
// That expiry is the point: the first version of this held the last TX value
|
// That expiry is the point: the first version of this held the last TX value
|
||||||
// with no timeout at all, so the meter sat frozen on the previous transmission
|
// with no timeout at all, so the meter sat frozen on the previous transmission
|
||||||
// indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward.
|
// indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward.
|
||||||
// Same 2 s window as the power peak above, so the two meters clear together.
|
// Same one-second window as the power peak above, so the two meters clear
|
||||||
|
// together.
|
||||||
const swrHold = useRef<{ v: number; t: number } | null>(null);
|
const swrHold = useRef<{ v: number; t: number } | null>(null);
|
||||||
if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() };
|
if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() };
|
||||||
const heldSwr = swrHold.current;
|
const heldSwr = swrHold.current;
|
||||||
const vswr = rawFwdW >= 1
|
const vswr = rawFwdW >= 1
|
||||||
? rawVswr
|
? rawVswr
|
||||||
: heldSwr && Date.now() - heldSwr.t < 2000
|
: heldSwr && Date.now() - heldSwr.t < 1000
|
||||||
? heldSwr.v
|
? heldSwr.v
|
||||||
: undefined;
|
: undefined;
|
||||||
const active = status.active ?? 1;
|
const active = status.active ?? 1;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export type TGChannel = {
|
|||||||
};
|
};
|
||||||
export type TGStatus = {
|
export type TGStatus = {
|
||||||
connected: boolean; host?: string; last_error?: string;
|
connected: boolean; host?: string; last_error?: string;
|
||||||
fwd_dbm?: number; fwd_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
|
fwd_dbm?: number; fwd_w?: number; peak_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
|
||||||
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
|
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
|
||||||
antenna?: number; three_way?: boolean; message?: string;
|
antenna?: number; three_way?: boolean; message?: string;
|
||||||
a?: TGChannel; b?: TGChannel;
|
a?: TGChannel; b?: TGChannel;
|
||||||
@@ -69,6 +69,10 @@ export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActiva
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||||
const active = status.active ?? 1;
|
const active = status.active ?? 1;
|
||||||
|
// The device's own peak, not the instantaneous reading: a 400 ms poll lands
|
||||||
|
// between syllables as often as on a peak, so the plain figure showed a few
|
||||||
|
// hundred watts on a kilowatt transmission.
|
||||||
|
const fwdW = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||||
@@ -103,7 +107,7 @@ export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActiva
|
|||||||
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
||||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div>
|
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div>
|
||||||
<div className="text-lg font-bold font-mono leading-tight text-foreground/80">
|
<div className="text-lg font-bold font-mono leading-tight text-foreground/80">
|
||||||
{status.fwd_w && status.fwd_w >= 1 ? `${Math.round(status.fwd_w)} W` : '—'}
|
{fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface Props {
|
|||||||
wpm: number;
|
wpm: number;
|
||||||
macros: WKMacro[];
|
macros: WKMacro[];
|
||||||
sent: string; // text echoed back by the keyer as it transmits
|
sent: string; // text echoed back by the keyer as it transmits
|
||||||
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu'; // CW output engine (chosen in Settings → CW Keyer)
|
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood'; // CW output engine (chosen in Settings → CW Keyer)
|
||||||
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||||
onSetBreakIn?: (mode: number) => void;
|
onSetBreakIn?: (mode: number) => void;
|
||||||
onSelectPort: (p: string) => void;
|
onSelectPort: (p: string) => void;
|
||||||
@@ -101,16 +101,16 @@ export function WinkeyerPanel({
|
|||||||
<Radio className="size-4 text-primary shrink-0" />
|
<Radio className="size-4 text-primary shrink-0" />
|
||||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||||
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : 'WinKeyer'}
|
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : source === 'kenwood' ? 'Kenwood CW' : 'WinKeyer'}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{source === 'icom' || source === 'flex' || source === 'yaesu' ? (
|
{source === 'icom' || source === 'flex' || source === 'yaesu' || source === 'kenwood' ? (
|
||||||
<span className="text-[11px] font-medium text-muted-foreground">
|
<span className="text-[11px] font-medium text-muted-foreground">
|
||||||
{source === 'flex'
|
{source === 'flex'
|
||||||
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
||||||
: source === 'yaesu'
|
: source === 'yaesu' || source === 'kenwood'
|
||||||
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
||||||
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+28
-16
File diff suppressed because one or more lines are too long
@@ -0,0 +1,83 @@
|
|||||||
|
// Sunrise / sunset for a station, in UTC.
|
||||||
|
//
|
||||||
|
// Built on greyline.ts's sunPosition() rather than on a second implementation of
|
||||||
|
// the solar equations: the map's terminator and the entry strip's sunrise must
|
||||||
|
// never disagree, and there is a whole comment block over there about a sign
|
||||||
|
// error that produced six months of plausible-looking wrong answers. One source
|
||||||
|
// of astronomy, one place to be wrong.
|
||||||
|
//
|
||||||
|
// Everything is UTC. Amateur radio runs on UTC, the log is in UTC, and a local
|
||||||
|
// time would raise the question "local to whom — me or the DX?".
|
||||||
|
import { sunPosition } from './greyline';
|
||||||
|
|
||||||
|
const DEG = Math.PI / 180;
|
||||||
|
|
||||||
|
// Standard altitude of the Sun's centre at rise/set: half a solar diameter below
|
||||||
|
// the horizon plus atmospheric refraction.
|
||||||
|
const H0 = -0.833;
|
||||||
|
|
||||||
|
// solarAltitude returns the Sun's elevation in degrees at a place and instant.
|
||||||
|
function solarAltitude(date: Date, latDeg: number, lonDeg: number): number {
|
||||||
|
const { dec, gha } = sunPosition(date);
|
||||||
|
const ha = (gha + lonDeg) * DEG; // local hour angle
|
||||||
|
const d = dec * DEG;
|
||||||
|
const lat = latDeg * DEG;
|
||||||
|
return Math.asin(Math.sin(lat) * Math.sin(d) + Math.cos(lat) * Math.cos(d) * Math.cos(ha)) / DEG;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SunTimes = {
|
||||||
|
rise: string; // "HH:MM" UTC, "" when the Sun does not rise that day
|
||||||
|
set: string;
|
||||||
|
polarDay: boolean; // Sun stays up all day
|
||||||
|
polarNight: boolean; // Sun never comes up
|
||||||
|
};
|
||||||
|
|
||||||
|
// sunTimes scans the UTC day a minute at a time and reports where the Sun
|
||||||
|
// crosses the rise/set altitude.
|
||||||
|
//
|
||||||
|
// A scan, not the closed-form hour-angle formula, because the closed form needs
|
||||||
|
// special-casing for the polar cases and for days where the declination shifts
|
||||||
|
// enough to matter, and it silently returns NaN rather than saying "no sunrise
|
||||||
|
// here today". 1440 evaluations is nothing, and the failure mode is honest: no
|
||||||
|
// crossing found means no sunrise, which is a real answer above the Arctic
|
||||||
|
// circle. The crossing minute is then refined by linear interpolation, so the
|
||||||
|
// result is accurate to well under a minute despite the coarse scan.
|
||||||
|
export function sunTimes(date: Date, latDeg: number, lonDeg: number): SunTimes {
|
||||||
|
const out: SunTimes = { rise: '', set: '', polarDay: false, polarNight: false };
|
||||||
|
if (!isFinite(latDeg) || !isFinite(lonDeg)) return out;
|
||||||
|
|
||||||
|
const start = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
||||||
|
const minute = 60000;
|
||||||
|
let prev = solarAltitude(new Date(start), latDeg, lonDeg);
|
||||||
|
const anyUp = prev > H0;
|
||||||
|
let anyDown = prev <= H0;
|
||||||
|
let sawUp = anyUp;
|
||||||
|
|
||||||
|
for (let i = 1; i <= 1440; i++) {
|
||||||
|
const alt = solarAltitude(new Date(start + i * minute), latDeg, lonDeg);
|
||||||
|
if (alt > H0) sawUp = true; else anyDown = true;
|
||||||
|
if (prev <= H0 && alt > H0 && !out.rise) {
|
||||||
|
out.rise = fmt(start + (i - 1 + frac(prev, alt)) * minute);
|
||||||
|
} else if (prev > H0 && alt <= H0 && !out.set) {
|
||||||
|
out.set = fmt(start + (i - 1 + frac(prev, alt)) * minute);
|
||||||
|
}
|
||||||
|
prev = alt;
|
||||||
|
}
|
||||||
|
if (!out.rise && !out.set) {
|
||||||
|
out.polarDay = sawUp && !anyDown;
|
||||||
|
out.polarNight = !sawUp;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// frac is where between two samples the altitude crosses H0.
|
||||||
|
function frac(a: number, b: number): number {
|
||||||
|
const d = b - a;
|
||||||
|
return d === 0 ? 0 : (H0 - a) / d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(ms: number): string {
|
||||||
|
const d = new Date(ms);
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.autofocusWB', // auto-focus Worked-before
|
'opslog.autofocusWB', // auto-focus Worked-before
|
||||||
'hamlog.filterPresets', // Filter Builder saved presets
|
'hamlog.filterPresets', // Filter Builder saved presets
|
||||||
'opslog.showRotor', // rotor compass shown next to the keyers
|
'opslog.showRotor', // rotor compass shown next to the keyers
|
||||||
|
'opslog.showAmpWidget', // amplifier widget shown next to the keyers
|
||||||
|
'opslog.ampSel.widget', // which amplifier that widget shows ("all" or an amp id)
|
||||||
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
||||||
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
||||||
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
|
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
|
||||||
@@ -36,6 +38,7 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||||
'opslog.activeTab', // last selected tab
|
'opslog.activeTab', // last selected tab
|
||||||
|
'opslog.mainSplit', // Main tab: width share of the left pane (percent)
|
||||||
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||||
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||||
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.22.7';
|
export const APP_VERSION = '0.23.5';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+21
-5
@@ -75,7 +75,7 @@ export function AudioStopTX():Promise<void>;
|
|||||||
|
|
||||||
export function AudioTXActive():Promise<boolean>;
|
export function AudioTXActive():Promise<boolean>;
|
||||||
|
|
||||||
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function AwardFields():Promise<Array<string>>;
|
export function AwardFields():Promise<Array<string>>;
|
||||||
|
|
||||||
@@ -97,6 +97,8 @@ export function CIVTraceEnabled():Promise<boolean>;
|
|||||||
|
|
||||||
export function CWDecoderRunning():Promise<boolean>;
|
export function CWDecoderRunning():Promise<boolean>;
|
||||||
|
|
||||||
|
export function CancelConfirmations():Promise<void>;
|
||||||
|
|
||||||
export function ChatAvailable():Promise<boolean>;
|
export function ChatAvailable():Promise<boolean>;
|
||||||
|
|
||||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||||
@@ -365,7 +367,7 @@ export function GetAudioSettings():Promise<main.AudioSettings>;
|
|||||||
|
|
||||||
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
||||||
|
|
||||||
export function GetAward(arg1:string):Promise<award.Result>;
|
export function GetAward(arg1:string,arg2:string):Promise<award.Result>;
|
||||||
|
|
||||||
export function GetAwardDefs():Promise<Array<award.Def>>;
|
export function GetAwardDefs():Promise<Array<award.Def>>;
|
||||||
|
|
||||||
@@ -469,7 +471,7 @@ export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
|||||||
|
|
||||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||||
|
|
||||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
||||||
|
|
||||||
export function GetSPEStatus():Promise<spe.Status>;
|
export function GetSPEStatus():Promise<spe.Status>;
|
||||||
|
|
||||||
@@ -607,6 +609,10 @@ export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
|||||||
|
|
||||||
export function IsNewUSCounty(arg1:string,arg2:string):Promise<boolean>;
|
export function IsNewUSCounty(arg1:string,arg2:string):Promise<boolean>;
|
||||||
|
|
||||||
|
export function KenwoodSendCW(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function KenwoodStopCW():Promise<void>;
|
||||||
|
|
||||||
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
||||||
|
|
||||||
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||||
@@ -707,6 +713,10 @@ export function PGXLSetFanMode(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function PGXLSetOperate(arg1:boolean):Promise<void>;
|
export function PGXLSetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function PTTHotkeyDown():Promise<void>;
|
||||||
|
|
||||||
|
export function PTTHotkeyUp():Promise<void>;
|
||||||
|
|
||||||
export function PickADIFMonitorFile():Promise<string>;
|
export function PickADIFMonitorFile():Promise<string>;
|
||||||
|
|
||||||
export function PickAudioFolder():Promise<string>;
|
export function PickAudioFolder():Promise<string>;
|
||||||
@@ -879,7 +889,7 @@ export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
|||||||
|
|
||||||
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
|
||||||
|
|
||||||
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||||
|
|
||||||
@@ -909,6 +919,8 @@ export function SendLogToDeveloper():Promise<void>;
|
|||||||
|
|
||||||
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetActiveRotor(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||||
@@ -929,6 +941,8 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetPassphrase(arg1:string):Promise<void>;
|
export function SetPassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||||
@@ -989,6 +1003,8 @@ export function SwitchCATRig(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||||
|
|
||||||
|
export function TailLogFile(arg1:number):Promise<string>;
|
||||||
|
|
||||||
export function TestCloudlogUpload():Promise<string>;
|
export function TestCloudlogUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestClublogUpload():Promise<string>;
|
export function TestClublogUpload():Promise<string>;
|
||||||
@@ -1009,7 +1025,7 @@ export function TestPTT(arg1:main.AudioSettings):Promise<void>;
|
|||||||
|
|
||||||
export function TestQRZUpload():Promise<string>;
|
export function TestQRZUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
export function TestRotatorDevice(arg1:main.RotatorDevice,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
||||||
|
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ export function AudioTXActive() {
|
|||||||
return window['go']['main']['App']['AudioTXActive']();
|
return window['go']['main']['App']['AudioTXActive']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AwardCellQSOs(arg1, arg2, arg3) {
|
export function AwardCellQSOs(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
|
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AwardFields() {
|
export function AwardFields() {
|
||||||
@@ -142,6 +142,10 @@ export function CWDecoderRunning() {
|
|||||||
return window['go']['main']['App']['CWDecoderRunning']();
|
return window['go']['main']['App']['CWDecoderRunning']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CancelConfirmations() {
|
||||||
|
return window['go']['main']['App']['CancelConfirmations']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatAvailable() {
|
export function ChatAvailable() {
|
||||||
return window['go']['main']['App']['ChatAvailable']();
|
return window['go']['main']['App']['ChatAvailable']();
|
||||||
}
|
}
|
||||||
@@ -678,8 +682,8 @@ export function GetAutostartPrograms() {
|
|||||||
return window['go']['main']['App']['GetAutostartPrograms']();
|
return window['go']['main']['App']['GetAutostartPrograms']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetAward(arg1) {
|
export function GetAward(arg1, arg2) {
|
||||||
return window['go']['main']['App']['GetAward'](arg1);
|
return window['go']['main']['App']['GetAward'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetAwardDefs() {
|
export function GetAwardDefs() {
|
||||||
@@ -886,8 +890,8 @@ export function GetRotatorHeading() {
|
|||||||
return window['go']['main']['App']['GetRotatorHeading']();
|
return window['go']['main']['App']['GetRotatorHeading']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetRotatorSettings() {
|
export function GetRotators() {
|
||||||
return window['go']['main']['App']['GetRotatorSettings']();
|
return window['go']['main']['App']['GetRotators']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetSPEStatus() {
|
export function GetSPEStatus() {
|
||||||
@@ -1162,6 +1166,14 @@ export function IsNewUSCounty(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['IsNewUSCounty'](arg1, arg2);
|
return window['go']['main']['App']['IsNewUSCounty'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function KenwoodSendCW(arg1) {
|
||||||
|
return window['go']['main']['App']['KenwoodSendCW'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KenwoodStopCW() {
|
||||||
|
return window['go']['main']['App']['KenwoodStopCW']();
|
||||||
|
}
|
||||||
|
|
||||||
export function LaunchAutostartProgram(arg1) {
|
export function LaunchAutostartProgram(arg1) {
|
||||||
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1362,6 +1374,14 @@ export function PGXLSetOperate(arg1) {
|
|||||||
return window['go']['main']['App']['PGXLSetOperate'](arg1);
|
return window['go']['main']['App']['PGXLSetOperate'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PTTHotkeyDown() {
|
||||||
|
return window['go']['main']['App']['PTTHotkeyDown']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PTTHotkeyUp() {
|
||||||
|
return window['go']['main']['App']['PTTHotkeyUp']();
|
||||||
|
}
|
||||||
|
|
||||||
export function PickADIFMonitorFile() {
|
export function PickADIFMonitorFile() {
|
||||||
return window['go']['main']['App']['PickADIFMonitorFile']();
|
return window['go']['main']['App']['PickADIFMonitorFile']();
|
||||||
}
|
}
|
||||||
@@ -1706,8 +1726,8 @@ export function SaveRelayAuto(arg1) {
|
|||||||
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1) {
|
export function SaveRotators(arg1) {
|
||||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
return window['go']['main']['App']['SaveRotators'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SaveStationDevices(arg1) {
|
export function SaveStationDevices(arg1) {
|
||||||
@@ -1766,6 +1786,10 @@ export function SendQSORecordingEmail(arg1) {
|
|||||||
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetActiveRotor(arg1) {
|
||||||
|
return window['go']['main']['App']['SetActiveRotor'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetAlertEmailTo(arg1) {
|
export function SetAlertEmailTo(arg1) {
|
||||||
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
|
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1806,6 +1830,10 @@ export function SetDVKLabel(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetKenwoodKeySpeed(arg1) {
|
||||||
|
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetPassphrase(arg1) {
|
export function SetPassphrase(arg1) {
|
||||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1926,6 +1954,10 @@ export function SyncPOTAHunterLog(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TailLogFile(arg1) {
|
||||||
|
return window['go']['main']['App']['TailLogFile'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function TestCloudlogUpload() {
|
export function TestCloudlogUpload() {
|
||||||
return window['go']['main']['App']['TestCloudlogUpload']();
|
return window['go']['main']['App']['TestCloudlogUpload']();
|
||||||
}
|
}
|
||||||
@@ -1966,8 +1998,8 @@ export function TestQRZUpload() {
|
|||||||
return window['go']['main']['App']['TestQRZUpload']();
|
return window['go']['main']['App']['TestQRZUpload']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestRotator(arg1) {
|
export function TestRotatorDevice(arg1, arg2) {
|
||||||
return window['go']['main']['App']['TestRotator'](arg1);
|
return window['go']['main']['App']['TestRotatorDevice'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestStationDevice(arg1) {
|
export function TestStationDevice(arg1) {
|
||||||
|
|||||||
@@ -1442,6 +1442,8 @@ export namespace lotwusers {
|
|||||||
is_user: boolean;
|
is_user: boolean;
|
||||||
last_upload?: string;
|
last_upload?: string;
|
||||||
days_ago: number;
|
days_ago: number;
|
||||||
|
feed_date?: string;
|
||||||
|
feed_stale: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Info(source);
|
return new Info(source);
|
||||||
@@ -1452,6 +1454,8 @@ export namespace lotwusers {
|
|||||||
this.is_user = source["is_user"];
|
this.is_user = source["is_user"];
|
||||||
this.last_upload = source["last_upload"];
|
this.last_upload = source["last_upload"];
|
||||||
this.days_ago = source["days_ago"];
|
this.days_ago = source["days_ago"];
|
||||||
|
this.feed_date = source["feed_date"];
|
||||||
|
this.feed_stale = source["feed_stale"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1516,6 +1520,7 @@ export namespace main {
|
|||||||
transport: string;
|
transport: string;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
password: string;
|
||||||
com_port: string;
|
com_port: string;
|
||||||
baud: number;
|
baud: number;
|
||||||
freq_out: boolean;
|
freq_out: boolean;
|
||||||
@@ -1536,6 +1541,7 @@ export namespace main {
|
|||||||
this.transport = source["transport"];
|
this.transport = source["transport"];
|
||||||
this.host = source["host"];
|
this.host = source["host"];
|
||||||
this.port = source["port"];
|
this.port = source["port"];
|
||||||
|
this.password = source["password"];
|
||||||
this.com_port = source["com_port"];
|
this.com_port = source["com_port"];
|
||||||
this.baud = source["baud"];
|
this.baud = source["baud"];
|
||||||
this.freq_out = source["freq_out"];
|
this.freq_out = source["freq_out"];
|
||||||
@@ -1924,6 +1930,9 @@ export namespace main {
|
|||||||
kenwood_host: string;
|
kenwood_host: string;
|
||||||
kenwood_port: string;
|
kenwood_port: string;
|
||||||
kenwood_baud: number;
|
kenwood_baud: number;
|
||||||
|
kenwood_data_mode: string;
|
||||||
|
yaesu_low_lines: boolean;
|
||||||
|
kenwood_low_lines: boolean;
|
||||||
icom_port: string;
|
icom_port: string;
|
||||||
icom_baud: number;
|
icom_baud: number;
|
||||||
icom_addr: number;
|
icom_addr: number;
|
||||||
@@ -1939,6 +1948,9 @@ export namespace main {
|
|||||||
digital_default: string;
|
digital_default: string;
|
||||||
share_enabled: boolean;
|
share_enabled: boolean;
|
||||||
share_port: number;
|
share_port: number;
|
||||||
|
ptt_hotkey_enabled: boolean;
|
||||||
|
ptt_hotkey: string;
|
||||||
|
ptt_hotkey_toggle: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new CATSettings(source);
|
return new CATSettings(source);
|
||||||
@@ -1964,6 +1976,9 @@ export namespace main {
|
|||||||
this.kenwood_host = source["kenwood_host"];
|
this.kenwood_host = source["kenwood_host"];
|
||||||
this.kenwood_port = source["kenwood_port"];
|
this.kenwood_port = source["kenwood_port"];
|
||||||
this.kenwood_baud = source["kenwood_baud"];
|
this.kenwood_baud = source["kenwood_baud"];
|
||||||
|
this.kenwood_data_mode = source["kenwood_data_mode"];
|
||||||
|
this.yaesu_low_lines = source["yaesu_low_lines"];
|
||||||
|
this.kenwood_low_lines = source["kenwood_low_lines"];
|
||||||
this.icom_port = source["icom_port"];
|
this.icom_port = source["icom_port"];
|
||||||
this.icom_baud = source["icom_baud"];
|
this.icom_baud = source["icom_baud"];
|
||||||
this.icom_addr = source["icom_addr"];
|
this.icom_addr = source["icom_addr"];
|
||||||
@@ -1979,6 +1994,9 @@ export namespace main {
|
|||||||
this.digital_default = source["digital_default"];
|
this.digital_default = source["digital_default"];
|
||||||
this.share_enabled = source["share_enabled"];
|
this.share_enabled = source["share_enabled"];
|
||||||
this.share_port = source["share_port"];
|
this.share_port = source["share_port"];
|
||||||
|
this.ptt_hotkey_enabled = source["ptt_hotkey_enabled"];
|
||||||
|
this.ptt_hotkey = source["ptt_hotkey"];
|
||||||
|
this.ptt_hotkey_toggle = source["ptt_hotkey_toggle"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class CabrilloResult {
|
export class CabrilloResult {
|
||||||
@@ -2796,11 +2814,52 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class RotatorDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
has_elevation: boolean;
|
||||||
|
rotator_num: number;
|
||||||
|
dual: boolean;
|
||||||
|
motorized: boolean;
|
||||||
|
name2: string;
|
||||||
|
motorized2: boolean;
|
||||||
|
transport: string;
|
||||||
|
com_port: string;
|
||||||
|
baud: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RotatorDevice(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.port = source["port"];
|
||||||
|
this.has_elevation = source["has_elevation"];
|
||||||
|
this.rotator_num = source["rotator_num"];
|
||||||
|
this.dual = source["dual"];
|
||||||
|
this.motorized = source["motorized"];
|
||||||
|
this.name2 = source["name2"];
|
||||||
|
this.motorized2 = source["motorized2"];
|
||||||
|
this.transport = source["transport"];
|
||||||
|
this.com_port = source["com_port"];
|
||||||
|
this.baud = source["baud"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class RotatorHeading {
|
export class RotatorHeading {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
azimuth: number;
|
azimuth: number;
|
||||||
raw: string;
|
raw: string;
|
||||||
|
rotors: string[];
|
||||||
|
active: number;
|
||||||
|
motorized: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new RotatorHeading(source);
|
return new RotatorHeading(source);
|
||||||
@@ -2812,34 +2871,9 @@ export namespace main {
|
|||||||
this.ok = source["ok"];
|
this.ok = source["ok"];
|
||||||
this.azimuth = source["azimuth"];
|
this.azimuth = source["azimuth"];
|
||||||
this.raw = source["raw"];
|
this.raw = source["raw"];
|
||||||
}
|
this.rotors = source["rotors"];
|
||||||
}
|
this.active = source["active"];
|
||||||
export class RotatorSettings {
|
this.motorized = source["motorized"];
|
||||||
enabled: boolean;
|
|
||||||
type: string;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
has_elevation: boolean;
|
|
||||||
rotator_num: number;
|
|
||||||
transport: string;
|
|
||||||
com_port: string;
|
|
||||||
baud: number;
|
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
|
||||||
return new RotatorSettings(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
|
||||||
this.enabled = source["enabled"];
|
|
||||||
this.type = source["type"];
|
|
||||||
this.host = source["host"];
|
|
||||||
this.port = source["port"];
|
|
||||||
this.has_elevation = source["has_elevation"];
|
|
||||||
this.rotator_num = source["rotator_num"];
|
|
||||||
this.transport = source["transport"];
|
|
||||||
this.com_port = source["com_port"];
|
|
||||||
this.baud = source["baud"];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class ScpStatus {
|
export class ScpStatus {
|
||||||
@@ -2900,6 +2934,8 @@ export namespace main {
|
|||||||
worked_call: boolean;
|
worked_call: boolean;
|
||||||
new_county: boolean;
|
new_county: boolean;
|
||||||
new_pota: boolean;
|
new_pota: boolean;
|
||||||
|
new_pfx: boolean;
|
||||||
|
pfx?: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new SpotStatus(source);
|
return new SpotStatus(source);
|
||||||
@@ -2916,6 +2952,8 @@ export namespace main {
|
|||||||
this.worked_call = source["worked_call"];
|
this.worked_call = source["worked_call"];
|
||||||
this.new_county = source["new_county"];
|
this.new_county = source["new_county"];
|
||||||
this.new_pota = source["new_pota"];
|
this.new_pota = source["new_pota"];
|
||||||
|
this.new_pfx = source["new_pfx"];
|
||||||
|
this.pfx = source["pfx"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class StartupStatus {
|
export class StartupStatus {
|
||||||
@@ -3497,6 +3535,11 @@ export namespace powergenius {
|
|||||||
fan_mode?: string;
|
fan_mode?: string;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
operate: boolean;
|
operate: boolean;
|
||||||
|
fwd_w: number;
|
||||||
|
peak_w: number;
|
||||||
|
vswr: number;
|
||||||
|
id: number;
|
||||||
|
peak_id: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Status(source);
|
return new Status(source);
|
||||||
@@ -3511,6 +3554,11 @@ export namespace powergenius {
|
|||||||
this.fan_mode = source["fan_mode"];
|
this.fan_mode = source["fan_mode"];
|
||||||
this.temperature = source["temperature"];
|
this.temperature = source["temperature"];
|
||||||
this.operate = source["operate"];
|
this.operate = source["operate"];
|
||||||
|
this.fwd_w = source["fwd_w"];
|
||||||
|
this.peak_w = source["peak_w"];
|
||||||
|
this.vswr = source["vswr"];
|
||||||
|
this.id = source["id"];
|
||||||
|
this.peak_id = source["peak_id"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4556,6 +4604,7 @@ export namespace spe {
|
|||||||
|
|
||||||
export class Status {
|
export class Status {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
|
transport: string;
|
||||||
last_error?: string;
|
last_error?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
operate: boolean;
|
operate: boolean;
|
||||||
@@ -4579,6 +4628,7 @@ export namespace spe {
|
|||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.connected = source["connected"];
|
this.connected = source["connected"];
|
||||||
|
this.transport = source["transport"];
|
||||||
this.last_error = source["last_error"];
|
this.last_error = source["last_error"];
|
||||||
this.model = source["model"];
|
this.model = source["model"];
|
||||||
this.operate = source["operate"];
|
this.operate = source["operate"];
|
||||||
@@ -4633,6 +4683,7 @@ export namespace tunergenius {
|
|||||||
last_error?: string;
|
last_error?: string;
|
||||||
fwd_dbm: number;
|
fwd_dbm: number;
|
||||||
fwd_w: number;
|
fwd_w: number;
|
||||||
|
peak_w: number;
|
||||||
swr_db: number;
|
swr_db: number;
|
||||||
vswr: number;
|
vswr: number;
|
||||||
operate: boolean;
|
operate: boolean;
|
||||||
@@ -4660,6 +4711,7 @@ export namespace tunergenius {
|
|||||||
this.last_error = source["last_error"];
|
this.last_error = source["last_error"];
|
||||||
this.fwd_dbm = source["fwd_dbm"];
|
this.fwd_dbm = source["fwd_dbm"];
|
||||||
this.fwd_w = source["fwd_w"];
|
this.fwd_w = source["fwd_w"];
|
||||||
|
this.peak_w = source["peak_w"];
|
||||||
this.swr_db = source["swr_db"];
|
this.swr_db = source["swr_db"];
|
||||||
this.vswr = source["vswr"];
|
this.vswr = source["vswr"];
|
||||||
this.operate = source["operate"];
|
this.operate = source["operate"];
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# ACOM built-in ATU — what we know, and why OpsLog does not drive it
|
||||||
|
|
||||||
|
Session of 2026-08-02 on F4BPO's friend's **ACOM 500S**, serial link, telemetry
|
||||||
|
trace. Written down so nobody repeats the work. **Conclusion first: the tuner
|
||||||
|
cannot be started over the serial link, and the attempt to work around that was
|
||||||
|
abandoned.**
|
||||||
|
|
||||||
|
## The tuner is PA state 8
|
||||||
|
|
||||||
|
Frame byte 3's high nibble is the PA status. ACOM's own list (recovered by the
|
||||||
|
ACOM-Controller project) has no entry for 8; it is the antenna tuner. Confirmed
|
||||||
|
twice, front-panel TUNE, with the byte-level trace running:
|
||||||
|
|
||||||
|
```
|
||||||
|
14:47:20.745 state 8 step 0 ← TUNE pressed. err=FF
|
||||||
|
14:47:22.588 operator keys a carrier (FT-891, FM, 31 W, 3.6358 MHz)
|
||||||
|
14:47:24.006 state 8 step 2
|
||||||
|
14:47:25.201 state 8 step 4
|
||||||
|
14:47:27.457 state 8 step 5
|
||||||
|
14:47:30.092 state 5 STANDBY ← accord finished
|
||||||
|
```
|
||||||
|
|
||||||
|
Byte 3's **low** nibble is a step counter through the accord: `0 → 2 → 4 → 5`.
|
||||||
|
Step 0 means *armed, waiting for RF* — in the first of the two runs the
|
||||||
|
amplifier sat there **30 seconds** untouched before the operator transmitted.
|
||||||
|
|
||||||
|
`paStatusNames[8] = "TUNE"` in acom.go is the only thing kept from all of this.
|
||||||
|
|
||||||
|
## The command does not exist in the state-command family
|
||||||
|
|
||||||
|
Every action byte of `55 81 08 02 00 XX 00 CHK` was tried, each one from a
|
||||||
|
**verified** STANDBY (an earlier sweep was worthless because its second code
|
||||||
|
parked the amp in SERVICE, where it silently refuses everything — 17 false
|
||||||
|
negatives). Result:
|
||||||
|
|
||||||
|
| byte | effect |
|
||||||
|
|------|--------|
|
||||||
|
| 0x01, 0x02 | **reboot the amplifier** — it prints `AMPLIFIER: ACOM 500S / HELLO ME…` |
|
||||||
|
| 0x04 | → SERVICE (state 4) |
|
||||||
|
| 0x05 | → STANDBY (state 5) |
|
||||||
|
| 0x06 | → OPERATE (state 6) |
|
||||||
|
| 0x0A | → OFF (state 10) |
|
||||||
|
| 0x03, 0x07..0x09, 0x0B..0x18 | nothing, state unchanged |
|
||||||
|
|
||||||
|
The action byte **is** the target PA state — four independent confirmations.
|
||||||
|
Which makes 0x08 the obvious candidate for TUNE. **It was tried, from a verified
|
||||||
|
STANDBY, and does nothing.** State 8 is reachable from the front panel only.
|
||||||
|
|
||||||
|
Going further would mean varying the other bytes of the frame. That is a large
|
||||||
|
space, and 0x01/0x02 prove that bad values reboot a 500 W amplifier. Not worth
|
||||||
|
it.
|
||||||
|
|
||||||
|
## The workaround that was built and removed
|
||||||
|
|
||||||
|
Since the amp waits, armed, for a carrier, OpsLog could watch for state 8 and
|
||||||
|
supply one: memorise the mode, drop the power, switch to FM, key the PTT, wait
|
||||||
|
for state 8 to clear, unkey, restore. It was implemented (`acomtune.go`, opt-in,
|
||||||
|
Settings → Amplifier) and **removed** after the first hardware test:
|
||||||
|
|
||||||
|
- the power change did not take effect, and
|
||||||
|
- **the PTT was not released when the tune finished.**
|
||||||
|
|
||||||
|
A logger that can leave a transceiver keyed is not acceptable, and the whole
|
||||||
|
thing was scaffolding around a protocol we do not actually control. Removed at
|
||||||
|
the operator's request: *"je veux qu'OpsLog soit solide et ça c'est de la
|
||||||
|
bidouille."*
|
||||||
|
|
||||||
|
If it is ever revisited, the unexplained part is why the tune-finished detection
|
||||||
|
did not fire — `anyAcomTuning()` polled `StateRaw == 8` every 200 ms and should
|
||||||
|
have seen the drop to STANDBY. Suspect the status snapshot, not the amplifier.
|
||||||
|
|
||||||
|
## Loose ends worth knowing
|
||||||
|
|
||||||
|
- **Frame byte 6 is a multiplexer.** The tail of the frame changes meaning with
|
||||||
|
it: `0x93` = bytes 48/49 carry the TX frequency in kHz (`62 1B` = 7010 while
|
||||||
|
the rig was on 7.010 MHz), `0x9D` = something else. Byte 70 looked like a
|
||||||
|
tuner flag for a while purely because of this — it toggles on its own several
|
||||||
|
times a minute. Do not read tail bytes without checking byte 6.
|
||||||
|
- **Byte 5 bit 0x80 alternates frame to frame.** It is a sequence/parity bit,
|
||||||
|
not a command acknowledgement.
|
||||||
|
- **Byte 66 (the error code) shows 0x14 / 0x8E / 0x69 / 0x0F during a normal
|
||||||
|
tune and during transmission.** `errText` renders those as "ERROR — see
|
||||||
|
display" and "Remove drive power", so an ACOM operator probably sees a false
|
||||||
|
alarm. This was never confirmed with the amplifier in OPERATE (it was in
|
||||||
|
STANDBY throughout, where drive genuinely is an error) — worth a look with a
|
||||||
|
clean log before touching the table.
|
||||||
@@ -77,9 +77,14 @@ var models = map[string]model{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// paStatusNames maps the PAstatus nibble to a display string.
|
// paStatusNames maps the PAstatus nibble to a display string.
|
||||||
|
// 8 is absent from the state list the ACOM-Controller project recovered; it is
|
||||||
|
// the antenna tuner, read off F4BPO's 500S on 2026-08-02. Pressing TUNE on the
|
||||||
|
// front panel took byte 3 from 0x51 to 0x80, the amp then WAITED 19 s doing
|
||||||
|
// nothing until the operator keyed a carrier, ran the tune over ~8 s while the
|
||||||
|
// low nibble stepped 0→2→4→5, and dropped back to 0x51 (STANDBY).
|
||||||
var paStatusNames = map[int]string{
|
var paStatusNames = map[int]string{
|
||||||
1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE",
|
1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE",
|
||||||
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 9: "SYSTEM", 10: "OFF",
|
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 8: "TUNE", 9: "SYSTEM", 10: "OFF",
|
||||||
}
|
}
|
||||||
|
|
||||||
// acomBands maps the band nibble to a band label.
|
// acomBands maps the band nibble to a band label.
|
||||||
|
|||||||
@@ -869,3 +869,22 @@ func (m *Manager) YaesuDo(fn func(YaesuController) error) error {
|
|||||||
return fn(yc)
|
return fn(yc)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KenwoodController is the Kenwood/Elecraft CW-over-CAT capability (the KY keyer),
|
||||||
|
// so a K3 can key CW through its single CAT link instead of a second COM port.
|
||||||
|
type KenwoodController interface {
|
||||||
|
SendCW(string) error
|
||||||
|
StopCW() error
|
||||||
|
SetKeySpeed(int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// KenwoodDo dispatches a Kenwood control onto the CAT goroutine.
|
||||||
|
func (m *Manager) KenwoodDo(fn func(KenwoodController) error) error {
|
||||||
|
return m.exec(func(b Backend) error {
|
||||||
|
kc, ok := b.(KenwoodController)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("active CAT backend is not a Kenwood/Elecraft")
|
||||||
|
}
|
||||||
|
return fn(kc)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+29
-22
@@ -61,10 +61,12 @@ type Flex struct {
|
|||||||
|
|
||||||
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
||||||
spotIdx map[int]bool // panadapter spot indices currently known to the radio
|
spotIdx map[int]bool // panadapter spot indices currently known to the radio
|
||||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||||
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
|
||||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
||||||
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||||
|
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
|
||||||
|
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
||||||
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
||||||
|
|
||||||
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
||||||
@@ -185,7 +187,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
|||||||
return &Flex{
|
return &Flex{
|
||||||
host: strings.TrimSpace(host), port: port,
|
host: strings.TrimSpace(host), port: port,
|
||||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||||
pinnedSlice: -1,
|
pinnedSlice: -1,
|
||||||
@@ -314,7 +316,12 @@ func (f *Flex) send(cmd string) int {
|
|||||||
debugLog.Printf("Flex: send %q failed: %v", cmd, err)
|
debugLog.Printf("Flex: send %q failed: %v", cmd, err)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
debugLog.Printf("Flex: → %s", cmd)
|
// Meter subscriptions are pure bookkeeping and a Flex announces meters one
|
||||||
|
// status line at a time — tracing each "sub meter N" wrote dozens of lines
|
||||||
|
// at every connect.
|
||||||
|
if !strings.HasPrefix(cmd, "sub meter ") {
|
||||||
|
debugLog.Printf("Flex: → %s", cmd)
|
||||||
|
}
|
||||||
return seq
|
return seq
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,11 +340,17 @@ func (f *Flex) reader(conn net.Conn) {
|
|||||||
if idx, err := strconv.Atoi(mm[1]); err == nil {
|
if idx, err := strconv.Atoi(mm[1]); err == nil {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
call := f.spotCall[idx]
|
call := f.spotCall[idx]
|
||||||
|
mode := f.spotMode[idx]
|
||||||
handler := f.OnSpotClick
|
handler := f.OnSpotClick
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if call != "" && handler != nil {
|
if call != "" && handler != nil {
|
||||||
debugLog.Printf("Flex: spot %d triggered → %s", idx, call)
|
debugLog.Printf("Flex: spot %d triggered → %s (mode %s)", idx, call, mode)
|
||||||
go handler(call, 0)
|
go handler(call, 0)
|
||||||
|
// SmartSDR tunes the spot's frequency on click but does NOT apply
|
||||||
|
// its mode=, so set the slice mode ourselves from what we spotted.
|
||||||
|
if mode != "" {
|
||||||
|
go func(m string) { _ = f.SetMode(m) }(mode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -374,12 +387,15 @@ func (f *Flex) reader(conn net.Conn) {
|
|||||||
// pair it with the callsign we stashed under this seq.
|
// pair it with the callsign we stashed under this seq.
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
call, pending := f.pendingSpot[seq]
|
call, pending := f.pendingSpot[seq]
|
||||||
|
spotMode := f.pendingSpotMode[seq]
|
||||||
if pending {
|
if pending {
|
||||||
delete(f.pendingSpot, seq)
|
delete(f.pendingSpot, seq)
|
||||||
|
delete(f.pendingSpotMode, seq)
|
||||||
}
|
}
|
||||||
if pending && ok && len(parts) >= 3 {
|
if pending && ok && len(parts) >= 3 {
|
||||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||||
f.spotCall[idx] = call
|
f.spotCall[idx] = call
|
||||||
|
f.spotMode[idx] = spotMode
|
||||||
f.spotIdx[idx] = true
|
f.spotIdx[idx] = true
|
||||||
f.spotByCall[strings.ToUpper(call)] = idx
|
f.spotByCall[strings.ToUpper(call)] = idx
|
||||||
}
|
}
|
||||||
@@ -773,17 +789,11 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
f.meterMeta[num] = old
|
f.meterMeta[num] = old
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
// One line for the whole batch, not one per meter: a Flex announces
|
// Not logged: the radio announces meters ONE per status line, so even
|
||||||
// dozens at connect and that was a wall of text every time.
|
// a batched line meant dozens of them at every connect.
|
||||||
var names []string
|
|
||||||
for _, id := range newIDs {
|
for _, id := range newIDs {
|
||||||
mi := f.meterMeta[id]
|
|
||||||
names = append(names, nonEmpty(mi.name, strconv.Itoa(id)))
|
|
||||||
f.subscribeMeter(id)
|
f.subscribeMeter(id)
|
||||||
}
|
}
|
||||||
if len(names) > 0 {
|
|
||||||
debugLog.Printf("Flex: subscribed to %d meter(s): %s", len(names), strings.Join(names, " "))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Spot status: "spot <index> …". Track the index so we can clear the
|
// Spot status: "spot <index> …". Track the index so we can clear the
|
||||||
// panadapter, and log it verbatim — a click on a panadapter spot pushes a
|
// panadapter, and log it verbatim — a click on a panadapter spot pushes a
|
||||||
@@ -802,6 +812,7 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
if removed {
|
if removed {
|
||||||
delete(f.spotIdx, idx)
|
delete(f.spotIdx, idx)
|
||||||
delete(f.spotCall, idx)
|
delete(f.spotCall, idx)
|
||||||
|
delete(f.spotMode, idx)
|
||||||
} else {
|
} else {
|
||||||
f.spotIdx[idx] = true
|
f.spotIdx[idx] = true
|
||||||
}
|
}
|
||||||
@@ -1264,6 +1275,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
|||||||
if hadOld {
|
if hadOld {
|
||||||
delete(f.spotByCall, upperCall)
|
delete(f.spotByCall, upperCall)
|
||||||
delete(f.spotCall, old)
|
delete(f.spotCall, old)
|
||||||
|
delete(f.spotMode, old)
|
||||||
delete(f.spotIdx, old)
|
delete(f.spotIdx, old)
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
@@ -1289,6 +1301,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
|||||||
// (trigger) can be resolved back to the callsign.
|
// (trigger) can be resolved back to the callsign.
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.pendingSpot[seq] = s.Callsign
|
f.pendingSpot[seq] = s.Callsign
|
||||||
|
f.pendingSpotMode[seq] = s.Mode
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -1321,6 +1334,7 @@ func (f *Flex) ClearSpots() error {
|
|||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.spotIdx = map[int]bool{}
|
f.spotIdx = map[int]bool{}
|
||||||
f.spotCall = map[int]string{}
|
f.spotCall = map[int]string{}
|
||||||
|
f.spotMode = map[int]string{}
|
||||||
f.spotByCall = map[string]int{}
|
f.spotByCall = map[string]int{}
|
||||||
connected := f.conn != nil
|
connected := f.conn != nil
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
@@ -2261,13 +2275,6 @@ func (f *Flex) subscribeMeter(id int) {
|
|||||||
f.send(fmt.Sprintf("sub meter %d", id))
|
f.send(fmt.Sprintf("sub meter %d", id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func nonEmpty(s, def string) string {
|
|
||||||
if s == "" {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseFloatDefault(s string, def float64) float64 {
|
func parseFloatDefault(s string, def float64) float64 {
|
||||||
if v, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
|
if v, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
|
||||||
return v
|
return v
|
||||||
|
|||||||
+66
-15
@@ -114,6 +114,15 @@ type IcomSerial struct {
|
|||||||
silentGrace time.Duration // current width of that tolerance (backs off, see ReadState)
|
silentGrace time.Duration // current width of that tolerance (backs off, see ReadState)
|
||||||
dspLoaded bool // readDSP has run since the rig became responsive (loads all
|
dspLoaded bool // readDSP has run since the rig became responsive (loads all
|
||||||
// the panel's set-once controls once the rig actually answers)
|
// the panel's set-once controls once the rig actually answers)
|
||||||
|
// When the console last asked for the DSP snapshot. The meters and the
|
||||||
|
// front-panel rotation are polled ONLY while something is displaying them:
|
||||||
|
// they are half of OpsLog's CI-V traffic, and on a shared bus — a microHAM
|
||||||
|
// Router splitting one CI-V link into two virtual ports, OpsLog on one and
|
||||||
|
// WSJT-X/MSHV on the other — every frame we don't need is a frame that can
|
||||||
|
// collide with the other program's.
|
||||||
|
dspWantMu sync.Mutex
|
||||||
|
dspWantAt time.Time
|
||||||
|
|
||||||
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
|
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
|
||||||
lastSetFreqAt time.Time
|
lastSetFreqAt time.Time
|
||||||
|
|
||||||
@@ -409,15 +418,25 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Live meters + TX state for the Icom panel (the rig doesn't push these).
|
// Live meters + TX state for the Icom panel (the rig doesn't push these).
|
||||||
|
// The meters are polled only while the console is actually on screen: they
|
||||||
|
// were three CI-V round trips per cycle, every cycle, whether or not anyone
|
||||||
|
// could see them. The previous readings are kept so the panel opens on the
|
||||||
|
// last known values rather than on zeros.
|
||||||
tx := b.readTX()
|
tx := b.readTX()
|
||||||
sm, _ := b.readMeter(civ.SubMeterS)
|
watched := b.panelWatched()
|
||||||
po, swr := 0, 0
|
b.dspMu.Lock()
|
||||||
if tx {
|
sm, po, swr := b.dsp.SMeter, b.dsp.PowerMeter, b.dsp.SWRMeter
|
||||||
if v, ok := b.readMeter(civ.SubMeterPo); ok {
|
b.dspMu.Unlock()
|
||||||
po = v
|
if watched {
|
||||||
}
|
sm, _ = b.readMeter(civ.SubMeterS)
|
||||||
if v, ok := b.readMeter(civ.SubMeterSWR); ok {
|
po, swr = 0, 0
|
||||||
swr = v
|
if tx {
|
||||||
|
if v, ok := b.readMeter(civ.SubMeterPo); ok {
|
||||||
|
po = v
|
||||||
|
}
|
||||||
|
if v, ok := b.readMeter(civ.SubMeterSWR); ok {
|
||||||
|
swr = v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.dspMu.Lock()
|
b.dspMu.Lock()
|
||||||
@@ -434,11 +453,15 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
|||||||
// snapshot so the panel's antenna, sliders, RIT, notch, etc. reflect the rig
|
// 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
|
// instead of sitting at their zero defaults. Runs once; ↻ Refresh re-reads on
|
||||||
// demand, and a reconnect re-arms it (Connect clears dspLoaded).
|
// demand, and a reconnect re-arms it (Connect clears dspLoaded).
|
||||||
if !b.dspLoaded {
|
// Both of these only make sense for the console — don't spend the bus on them
|
||||||
b.readDSP()
|
// while it is closed. The snapshot then loads the first time it is opened.
|
||||||
b.dspLoaded = true
|
if watched {
|
||||||
} else {
|
if !b.dspLoaded {
|
||||||
b.refreshFrontPanel()
|
b.readDSP()
|
||||||
|
b.dspLoaded = true
|
||||||
|
} else {
|
||||||
|
b.refreshFrontPanel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
@@ -532,12 +555,25 @@ func (b *IcomSerial) SetPower(on bool) error {
|
|||||||
return fmt.Errorf("icom: not connected")
|
return fmt.Errorf("icom: not connected")
|
||||||
}
|
}
|
||||||
if on {
|
if on {
|
||||||
buf := make([]byte, 0, 32)
|
// The preamble has to last long enough IN TIME for the sleeping rig to
|
||||||
for i := 0; i < 25; i++ {
|
// notice it, so its length scales with the baud rate — Icom's own table
|
||||||
|
// asks for 7 bytes at 4800 baud and 150 at 115200, which is a fixed
|
||||||
|
// ~25 ms of carrier. A flat 25 bytes (what this sent before) is right at
|
||||||
|
// 19200 and far too short above it, so a rig configured for 115200 simply
|
||||||
|
// ignored the command.
|
||||||
|
n := b.baud / 768
|
||||||
|
if n < 7 {
|
||||||
|
n = 7
|
||||||
|
} else if n > 150 {
|
||||||
|
n = 150
|
||||||
|
}
|
||||||
|
buf := make([]byte, 0, n+8)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
buf = append(buf, 0xFE)
|
buf = append(buf, 0xFE)
|
||||||
}
|
}
|
||||||
buf = append(buf, 0xFE, 0xFE, b.rigAddr, civ.AddrController, civ.CmdPower, 0x01, 0xFD)
|
buf = append(buf, 0xFE, 0xFE, b.rigAddr, civ.AddrController, civ.CmdPower, 0x01, 0xFD)
|
||||||
_, err := b.port.Write(buf)
|
_, err := b.port.Write(buf)
|
||||||
|
applog.Printf("icom: power ON — %d-byte wake preamble at %d baud, addr 0x%02X (err=%v)", n, b.baud, b.rigAddr, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, civ.CmdPower, 0x00))
|
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, civ.CmdPower, 0x00))
|
||||||
@@ -1289,11 +1325,26 @@ func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
|
|||||||
// ── IcomController: receive-DSP controls for the Icom tab ───────────────────
|
// ── IcomController: receive-DSP controls for the Icom tab ───────────────────
|
||||||
|
|
||||||
func (b *IcomSerial) IcomState() IcomTXState {
|
func (b *IcomSerial) IcomState() IcomTXState {
|
||||||
|
// Asking for the snapshot is what marks the console as being watched, which
|
||||||
|
// is what re-enables the meter polling in ReadState.
|
||||||
|
b.dspWantMu.Lock()
|
||||||
|
b.dspWantAt = time.Now()
|
||||||
|
b.dspWantMu.Unlock()
|
||||||
|
|
||||||
b.dspMu.Lock()
|
b.dspMu.Lock()
|
||||||
defer b.dspMu.Unlock()
|
defer b.dspMu.Unlock()
|
||||||
return b.dsp
|
return b.dsp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// panelWatched reports whether the Icom console asked for the DSP snapshot
|
||||||
|
// recently — i.e. whether anything is on screen to read the meters.
|
||||||
|
func (b *IcomSerial) panelWatched() bool {
|
||||||
|
b.dspWantMu.Lock()
|
||||||
|
at := b.dspWantAt
|
||||||
|
b.dspWantMu.Unlock()
|
||||||
|
return !at.IsZero() && time.Since(at) < 3*time.Second
|
||||||
|
}
|
||||||
|
|
||||||
// RefreshIcom re-reads the whole DSP snapshot from the rig. Runs on the CAT
|
// RefreshIcom re-reads the whole DSP snapshot from the rig. Runs on the CAT
|
||||||
// goroutine (dispatched via IcomDo).
|
// goroutine (dispatched via IcomDo).
|
||||||
func (b *IcomSerial) RefreshIcom() error {
|
func (b *IcomSerial) RefreshIcom() error {
|
||||||
|
|||||||
+194
-47
@@ -29,6 +29,7 @@ package cat
|
|||||||
// from the radio, and the rig file decides what a "Freq" property means.
|
// from the radio, and the rig file decides what a "Freq" property means.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -55,6 +56,12 @@ type Kenwood struct {
|
|||||||
// a wire, which is how most operators actually put a rig on the network.
|
// a wire, which is how most operators actually put a rig on the network.
|
||||||
host string
|
host string
|
||||||
digital string // mode name logged for data (FT8 by default)
|
digital string // mode name logged for data (FT8 by default)
|
||||||
|
// dataMode chooses what CAT mode a DATA/digital mode (FT8, PSK…) sets, because
|
||||||
|
// no single Kenwood mode digit is right for every rig: "usb" → USB (the SSB-data
|
||||||
|
// base, default), "data" → the DATA mode digit MD6 (Elecraft K3/K4), "keep" →
|
||||||
|
// leave the rig's current mode untouched (safest for a TS-590SG/TS-990S whose
|
||||||
|
// data mode is a USB modifier the operator sets on the rig). See SetMode.
|
||||||
|
dataMode string
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
port serial.Port
|
port serial.Port
|
||||||
@@ -69,8 +76,62 @@ type Kenwood struct {
|
|||||||
curFreq int64
|
curFreq int64
|
||||||
curRXFreq int64
|
curRXFreq int64
|
||||||
curVFO string // "A" or "B"
|
curVFO string // "A" or "B"
|
||||||
|
// Split is confirmed with FR;/FT; because IF's split bit is empty on some
|
||||||
|
// Kenwood-dialect rigs — but that check is THROTTLED so it doesn't run every
|
||||||
|
// poll: the two extra commands tripled the poll time on a slow K3 and made the
|
||||||
|
// frequency (read from IF at the top of the poll) lag by seconds. Between
|
||||||
|
// checks the last result stands.
|
||||||
|
splitCheckAt time.Time
|
||||||
|
splitCached bool
|
||||||
|
splitVFOCached string
|
||||||
|
keyWPM int // CW keyer speed, for pacing the KY buffer (see kenwood_cw.go)
|
||||||
// Commands this rig answered "?;" to — asked once, then never again.
|
// Commands this rig answered "?;" to — asked once, then never again.
|
||||||
unsupported map[string]bool
|
unsupported map[string]bool
|
||||||
|
|
||||||
|
// rx holds bytes read but not yet consumed, ACROSS calls to ask.
|
||||||
|
//
|
||||||
|
// It has to survive: a rig answers faster than we ask, so one Read often
|
||||||
|
// returns a whole reply plus the start of the next frame. When this buffer
|
||||||
|
// was local to ask, everything after the matched frame was dropped — half a
|
||||||
|
// frame included — and the link desynchronised permanently: every ask then
|
||||||
|
// found the PREVIOUS command's answer and timed out waiting for its own.
|
||||||
|
// That is the "discarding \" 000000000010000000;\" while waiting for IF"
|
||||||
|
// a TS-480 reported, followed by connected=false for ever.
|
||||||
|
rx []byte
|
||||||
|
|
||||||
|
// heard is whatever arrived during Connect that was not a reply we wanted.
|
||||||
|
// Kept only to put it in the error message: "not answering" and "answering
|
||||||
|
// something unreadable" are different faults with different fixes.
|
||||||
|
heard string
|
||||||
|
|
||||||
|
// lowerLines deasserts DTR and RTS after opening the port.
|
||||||
|
//
|
||||||
|
// Neither default is safe for everyone, which is why this is a setting and
|
||||||
|
// not a decision. Windows raises both lines on open: an interface that reads
|
||||||
|
// them as PTT then keys the rig for as long as OpsLog is running — reported
|
||||||
|
// as FT8 output power wandering, and gone the moment OpsLog was closed.
|
||||||
|
// Lowering them instead silences the radios whose USB-serial interface needs
|
||||||
|
// RTS asserted to transmit at all — a TS-990 that opened cleanly and answered
|
||||||
|
// nothing. Off by default: that is how this backend behaved for its whole
|
||||||
|
// life before the question came up.
|
||||||
|
lowerLines bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set
|
||||||
|
// before Connect.
|
||||||
|
func (k *Kenwood) SetLowerLines(v bool) {
|
||||||
|
k.mu.Lock()
|
||||||
|
k.lowerLines = v
|
||||||
|
k.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// where names the link for a message, so an error does not read "COM @ 0 baud"
|
||||||
|
// after a network connect.
|
||||||
|
func (k *Kenwood) where() string {
|
||||||
|
if k.host != "" {
|
||||||
|
return k.host
|
||||||
|
}
|
||||||
|
return k.portName
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewKenwoodTCP builds a backend that reaches the rig over a socket instead of
|
// NewKenwoodTCP builds a backend that reaches the rig over a socket instead of
|
||||||
@@ -122,6 +183,14 @@ func (k *Kenwood) Connect() error {
|
|||||||
// request/response pairs and make a reply impossible to attribute. We poll.
|
// request/response pairs and make a reply impossible to attribute. We poll.
|
||||||
_ = k.write("AI0;")
|
_ = k.write("AI0;")
|
||||||
|
|
||||||
|
// Start from silence. A reconnect inherits whatever the rig said last —
|
||||||
|
// unsolicited frames sent before AI0 landed, the tail of an answer nobody
|
||||||
|
// read — and one stale frame is enough to leave every ask one reply behind
|
||||||
|
// its question for the rest of the session.
|
||||||
|
k.rx = nil
|
||||||
|
k.drain(250 * time.Millisecond)
|
||||||
|
k.heard = ""
|
||||||
|
|
||||||
answered := false
|
answered := false
|
||||||
if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") {
|
if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") {
|
||||||
answered = true
|
answered = true
|
||||||
@@ -140,10 +209,22 @@ func (k *Kenwood) Connect() error {
|
|||||||
}
|
}
|
||||||
if !answered {
|
if !answered {
|
||||||
k.model = ""
|
k.model = ""
|
||||||
if k.host != "" {
|
if k.heard == "" && len(k.rx) > 0 {
|
||||||
return fmt.Errorf("kenwood: %s accepted the connection but the rig is not answering — check that the serial bridge points at the radio and that the radio is switched on", k.host)
|
k.heard = string(k.rx) // an unterminated fragment is evidence too
|
||||||
}
|
}
|
||||||
return fmt.Errorf("kenwood: %s opened but the rig is not answering — check that it is switched on and set to %d baud", k.portName, k.baud)
|
// Distinguish silence from noise. "The rig is not answering" sent an
|
||||||
|
// operator checking the power switch and the baud rate on a radio that was
|
||||||
|
// visibly talking — its frames were arriving, they just did not match what
|
||||||
|
// was asked (wrong baud garbles them; an interface echoing our own
|
||||||
|
// commands back produces the same). Say which of the two it is, and quote
|
||||||
|
// what came back, because that is the fact that decides where to look.
|
||||||
|
if seen := k.heard; seen != "" {
|
||||||
|
return fmt.Errorf("kenwood: %s is sending data but no reply to ID; or IF; — got %q. Check the baud rate (set to %d here) and that nothing else is echoing the port", k.where(), seen, k.baud)
|
||||||
|
}
|
||||||
|
if k.host != "" {
|
||||||
|
return fmt.Errorf("kenwood: %s accepted the connection but the rig sent nothing — check that the serial bridge points at the radio and that the radio is switched on", k.host)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("kenwood: %s opened but the rig sent nothing — check that it is switched on and set to %d baud", k.portName, k.baud)
|
||||||
}
|
}
|
||||||
// Name what was actually connected to. A log reading "connected on @ 0 baud"
|
// Name what was actually connected to. A log reading "connected on @ 0 baud"
|
||||||
// after a network connect is the kind of line that sends someone hunting a
|
// after a network connect is the kind of line that sends someone hunting a
|
||||||
@@ -203,18 +284,31 @@ func (k *Kenwood) ReadState() (RigState, error) {
|
|||||||
// this costs two short commands per poll only where it actually works.
|
// this costs two short commands per poll only where it actually works.
|
||||||
split := f.Split
|
split := f.Split
|
||||||
if !split {
|
if !split {
|
||||||
rxv, rxOK := k.askVFO("FR;")
|
// Confirm with FR;/FT; only once a second, not every poll. Running the two
|
||||||
txv, txOK := k.askVFO("FT;")
|
// extra commands each cycle tripled the poll time on a slow K3, so the
|
||||||
if rxOK && txOK && rxv != txv {
|
// frequency — already read from IF above — only surfaced every couple of
|
||||||
|
// seconds. Between checks the last FR/FT result stands.
|
||||||
|
if time.Since(k.splitCheckAt) >= time.Second {
|
||||||
|
k.splitCheckAt = time.Now()
|
||||||
|
k.splitCached, k.splitVFOCached = false, ""
|
||||||
|
rxv, rxOK := k.askVFO("FR;")
|
||||||
|
txv, txOK := k.askVFO("FT;")
|
||||||
|
if rxOK && txOK && rxv != txv {
|
||||||
|
k.splitCached = true
|
||||||
|
// Trust FR over IF for which VFO is in use: they were asked in the
|
||||||
|
// same breath, and a rig that leaves the split bit empty may be just
|
||||||
|
// as vague about the VFO field. The block below picks the TRANSMIT
|
||||||
|
// VFO as "the other one" from f.VFO, so leaving them disagreeing
|
||||||
|
// would read the transmit frequency off the wrong dial.
|
||||||
|
if rxv == "A" || rxv == "B" {
|
||||||
|
k.splitVFOCached = rxv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if k.splitCached {
|
||||||
split = true
|
split = true
|
||||||
// Trust FR over IF for which VFO is in use: they were asked in the
|
if k.splitVFOCached != "" {
|
||||||
// same breath, and a rig that leaves the split bit empty may be just
|
k.curVFO, s.Vfo, f.VFO = k.splitVFOCached, k.splitVFOCached, k.splitVFOCached
|
||||||
// as vague about the VFO field.
|
|
||||||
// f.VFO too, not just the reported state: the block below picks the
|
|
||||||
// TRANSMIT VFO as "the other one" from f.VFO, and leaving the two
|
|
||||||
// disagreeing would read the transmit frequency off the wrong dial.
|
|
||||||
if rxv == "A" || rxv == "B" {
|
|
||||||
k.curVFO, s.Vfo, f.VFO = rxv, rxv, rxv
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,6 +366,17 @@ func (k *Kenwood) SetMode(mode string) error {
|
|||||||
if k.port == nil {
|
if k.port == nil {
|
||||||
return fmt.Errorf("kenwood: not connected")
|
return fmt.Errorf("kenwood: not connected")
|
||||||
}
|
}
|
||||||
|
// Honour the operator's DATA-mode preference for a digital mode. No single
|
||||||
|
// mode digit fits every rig, so the operator picks: keep the rig's mode, use
|
||||||
|
// MD6 (K3/K4 DATA), or fall through to the default USB from kenwoodModeDigit.
|
||||||
|
if isKenwoodDataMode(mode) {
|
||||||
|
switch k.dataMode {
|
||||||
|
case "keep":
|
||||||
|
return nil // leave whatever data mode the operator set on the rig
|
||||||
|
case "data":
|
||||||
|
return k.write("MD6;") // Elecraft K3/K4 DATA mode
|
||||||
|
}
|
||||||
|
}
|
||||||
d := kenwoodModeDigit(mode, k.curFreq)
|
d := kenwoodModeDigit(mode, k.curFreq)
|
||||||
if d == 0 {
|
if d == 0 {
|
||||||
return fmt.Errorf("kenwood: no CAT mode for %q", mode)
|
return fmt.Errorf("kenwood: no CAT mode for %q", mode)
|
||||||
@@ -279,6 +384,25 @@ func (k *Kenwood) SetMode(mode string) error {
|
|||||||
return k.write(fmt.Sprintf("MD%c;", d))
|
return k.write(fmt.Sprintf("MD%c;", d))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDataMode configures how a DATA/digital mode is set: "usb" (default), "data"
|
||||||
|
// (MD6, Elecraft K3/K4) or "keep" (don't change the rig's mode).
|
||||||
|
func (k *Kenwood) SetDataMode(m string) {
|
||||||
|
k.mu.Lock()
|
||||||
|
k.dataMode = strings.ToLower(strings.TrimSpace(m))
|
||||||
|
k.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// isKenwoodDataMode reports whether a mode name is a soundcard/data mode (FT8,
|
||||||
|
// PSK, JT…) rather than a voice/CW/RTTY mode the rig sets natively.
|
||||||
|
func isKenwoodDataMode(mode string) bool {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "", "LSB", "USB", "SSB", "CW", "CW-R", "CWR", "FM", "NFM", "AM",
|
||||||
|
"RTTY", "FSK", "RTTY-R", "FSK-R":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (k *Kenwood) SetPTT(on bool) error {
|
func (k *Kenwood) SetPTT(on bool) error {
|
||||||
k.mu.Lock()
|
k.mu.Lock()
|
||||||
defer k.mu.Unlock()
|
defer k.mu.Unlock()
|
||||||
@@ -300,6 +424,23 @@ func (k *Kenwood) write(cmd string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drain reads and throws away whatever the rig has already sent, until it stays
|
||||||
|
// quiet for one read timeout or the budget runs out.
|
||||||
|
func (k *Kenwood) drain(budget time.Duration) {
|
||||||
|
if k.port == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmp := make([]byte, 256)
|
||||||
|
deadline := time.Now().Add(budget)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, err := k.port.Read(tmp)
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
return // an error here is not interesting: we are throwing this away
|
||||||
|
}
|
||||||
|
traceText("kenwood", "RX-drop", string(tmp[:n]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ask sends a query and returns the reply belonging to THAT command. Anything
|
// ask sends a query and returns the reply belonging to THAT command. Anything
|
||||||
// else on the wire is discarded: a stray frame parsed as a frequency reads as
|
// else on the wire is discarded: a stray frame parsed as a frequency reads as
|
||||||
// "lost the rig" to the Manager, which then reconnects — the CAT link dropping
|
// "lost the rig" to the Manager, which then reconnects — the CAT link dropping
|
||||||
@@ -312,25 +453,18 @@ func (k *Kenwood) ask(cmd string) (string, error) {
|
|||||||
if err := k.write(cmd); err != nil {
|
if err := k.write(cmd); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
buf := make([]byte, 0, 64)
|
|
||||||
tmp := make([]byte, 64)
|
tmp := make([]byte, 64)
|
||||||
deadline := time.Now().Add(600 * time.Millisecond)
|
deadline := time.Now().Add(600 * time.Millisecond)
|
||||||
for time.Now().Before(deadline) {
|
for {
|
||||||
n, err := k.port.Read(tmp)
|
// Consume whatever is already buffered BEFORE reading more: the answer
|
||||||
if err != nil {
|
// may have arrived attached to the previous one.
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
continue // read timeout — the rig may still be composing its answer
|
|
||||||
}
|
|
||||||
buf = append(buf, tmp[:n]...)
|
|
||||||
for {
|
for {
|
||||||
i := strings.IndexByte(string(buf), ';')
|
i := bytes.IndexByte(k.rx, ';')
|
||||||
if i < 0 {
|
if i < 0 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
frame := string(buf[:i+1])
|
frame := string(k.rx[:i+1])
|
||||||
buf = buf[i+1:]
|
k.rx = k.rx[i+1:]
|
||||||
traceText("kenwood", "RX", frame)
|
traceText("kenwood", "RX", frame)
|
||||||
if frame == "?;" {
|
if frame == "?;" {
|
||||||
// The rig rejected the command. Remember it so the poll loop stops
|
// The rig rejected the command. Remember it so the poll loop stops
|
||||||
@@ -343,9 +477,25 @@ func (k *Kenwood) ask(cmd string) (string, error) {
|
|||||||
return frame, nil
|
return frame, nil
|
||||||
}
|
}
|
||||||
debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want)
|
debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want)
|
||||||
|
// Remember the first unexpected frame: if the whole handshake fails, this
|
||||||
|
// is what tells the operator the radio was talking after all.
|
||||||
|
if k.heard == "" {
|
||||||
|
k.heard = frame
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if !time.Now().Before(deadline) {
|
||||||
|
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
|
||||||
|
}
|
||||||
|
n, err := k.port.Read(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
k.rx = append(k.rx, tmp[:n]...)
|
||||||
|
}
|
||||||
|
// n == 0 is a read timeout, not silence for good: the rig may still be
|
||||||
|
// composing its answer.
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Frame parsing ──────────────────────────────────────────────────────────
|
// ── Frame parsing ──────────────────────────────────────────────────────────
|
||||||
@@ -437,11 +587,12 @@ func kenwoodModeDigit(mode string, hz int64) byte {
|
|||||||
}
|
}
|
||||||
return '2'
|
return '2'
|
||||||
}
|
}
|
||||||
// Any other digital mode rides on the data sideband, which on Kenwood is
|
// Any other digital mode (FT8, PSK, JT…) → USB on every band. There is NO single
|
||||||
// plain USB/LSB with the rig's DATA input selected.
|
// mode digit that is right for both families: a K3 wants DATA (MD6), a TS-590SG
|
||||||
if hz > 0 && hz < 10_000_000 {
|
// wants USB-DATA (MD2 + its DATA modifier), and MD6 on the TS-590SG is FSK/RTTY
|
||||||
return '1'
|
// — wrong for FT8. USB is the safe common base (the reversed-sideband "DATA REV"
|
||||||
}
|
// only came from the old LSB-below-10-MHz rule). A rig-specific DATA mode is a
|
||||||
|
// follow-up that needs the model, not a blind default.
|
||||||
return '2'
|
return '2'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +626,10 @@ var kenwoodModels = map[string]string{
|
|||||||
"019": "TS-2000",
|
"019": "TS-2000",
|
||||||
"020": "TS-480",
|
"020": "TS-480",
|
||||||
"021": "TS-590S",
|
"021": "TS-590S",
|
||||||
|
// 022 reported by a real TS-990S in the field. Kenwood's documentation gives
|
||||||
|
// 024 for that radio, so both are kept: the observed value wins where they
|
||||||
|
// disagree, and neither maps to anything else.
|
||||||
|
"022": "TS-990S",
|
||||||
"023": "TS-590SG",
|
"023": "TS-590SG",
|
||||||
"024": "TS-990S",
|
"024": "TS-990S",
|
||||||
"025": "TS-890S",
|
"025": "TS-890S",
|
||||||
@@ -496,20 +651,12 @@ func (k *Kenwood) openPort() (serial.Port, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Deassert DTR and RTS.
|
// The modem lines are only touched when the operator asks for it. See
|
||||||
//
|
// lowerLines: both defaults break somebody's station.
|
||||||
// Windows raises both when a serial port is opened, and a great many
|
if k.lowerLines {
|
||||||
// interfaces read them as PTT: a Xiegu G90 behind a DE-19 goes into
|
_ = p.SetDTR(false)
|
||||||
// transmit the moment OpsLog connects and STAYS there — Xiegu's own
|
_ = p.SetRTS(false)
|
||||||
// documentation asks for RTS and DTR low. The same applies to an Icom with
|
}
|
||||||
// USB SEND mapped to a line (which is why the CI-V backend has done this
|
|
||||||
// from the start) and to any rig keyed by a home-made cable.
|
|
||||||
//
|
|
||||||
// PTT on this backend is a CAT command, so neither line should ever be
|
|
||||||
// asserted here. A station keying by RTS/DTR configures that separately,
|
|
||||||
// on its own port.
|
|
||||||
_ = p.SetDTR(false)
|
|
||||||
_ = p.SetRTS(false)
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
// CW keying through the Kenwood/Elecraft keyer — the KY command.
|
||||||
|
//
|
||||||
|
// Same idea as the Yaesu KY engine and the Icom / Flex keyers: the radio holds
|
||||||
|
// the text and keys it with its own timing, so an Elecraft K3 (or any rig that
|
||||||
|
// speaks this dialect) needs NO WinKeyer and NO second COM port — the single CAT
|
||||||
|
// link does frequency, mode AND CW. That matters on a K3, whose one USB port is
|
||||||
|
// the CAT port; a separate serial keyer would need a second cable OpsLog can't
|
||||||
|
// give it.
|
||||||
|
//
|
||||||
|
// KY; → KYn; n=0 buffer has room, n=1 buffer full
|
||||||
|
// KY <text>; queue up to 24 characters (the space after KY is part
|
||||||
|
// of the command, not padding)
|
||||||
|
// KS nnn; keyer speed in WPM (three digits)
|
||||||
|
// RX; drop to receive — used to abort a send
|
||||||
|
//
|
||||||
|
// KY is the Elecraft-documented CW-over-CAT path on the K3/K4. A rig that refuses
|
||||||
|
// it answers "?;", which SendCW turns into a stated reason rather than silence.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// kenwoodCWChunk is the most characters one KY command accepts.
|
||||||
|
const kenwoodCWChunk = 24
|
||||||
|
|
||||||
|
// kenwoodCWAllowed is what the keyer can send; anything else is dropped, since an
|
||||||
|
// unsupported byte can abort the buffer and lose the rest of the message.
|
||||||
|
const kenwoodCWAllowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 /?.,-=+:;()"
|
||||||
|
|
||||||
|
// SendCW queues a message on the rig's keyer, fed in 24-character pieces, waiting
|
||||||
|
// for buffer room between pieces so a long macro doesn't lose its tail.
|
||||||
|
func (k *Kenwood) SendCW(text string) error {
|
||||||
|
msg := filterKenwoodCW(text)
|
||||||
|
if msg == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
for len(msg) > 0 {
|
||||||
|
n := kenwoodCWChunk
|
||||||
|
if len(msg) < n {
|
||||||
|
n = len(msg)
|
||||||
|
}
|
||||||
|
chunk := msg[:n]
|
||||||
|
msg = msg[n:]
|
||||||
|
k.waitCWBuffer(3 * time.Second)
|
||||||
|
if err := k.write("KY " + chunk + ";"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := k.afterKY(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Pace the next piece by how long this one takes to key, so we never overrun
|
||||||
|
// the 24-character buffer (the rig silently drops what doesn't fit).
|
||||||
|
if len(msg) > 0 {
|
||||||
|
time.Sleep(kenwoodCWDuration(chunk, k.keyerWPM()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// afterKY reads briefly after a KY write. An accepted KY says nothing; a REJECT
|
||||||
|
// answers "?;". Reading it straight off the port (not through the shared rx
|
||||||
|
// buffer) keeps that stray frame from being picked up by the next poll's ask —
|
||||||
|
// which would mis-mark an unrelated command unsupported and desync the link — and
|
||||||
|
// turns a silent non-transmission into a stated reason. The caller holds k.mu.
|
||||||
|
func (k *Kenwood) afterKY() error {
|
||||||
|
deadline := time.Now().Add(150 * time.Millisecond)
|
||||||
|
tmp := make([]byte, 64)
|
||||||
|
var buf []byte
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, err := k.port.Read(tmp)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
buf = append(buf, tmp[:n]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(string(buf), "?;") {
|
||||||
|
return fmt.Errorf("this radio rejected CW over CAT (it answered \"?;\" to KY). " +
|
||||||
|
"Switch the keyer engine to the serial-port keyer (DTR=CW) on a COM port instead")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitCWBuffer blocks until the keyer reports room, or the deadline passes. A rig
|
||||||
|
// that never answers the KY; status query is not a reason to refuse to send — we
|
||||||
|
// go ahead, and the per-chunk pacing covers the worst case. The caller holds k.mu.
|
||||||
|
func (k *Kenwood) waitCWBuffer(within time.Duration) {
|
||||||
|
deadline := time.Now().Add(within)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if k.unsupported["KY"] {
|
||||||
|
return // this rig doesn't report buffer state — pacing covers it
|
||||||
|
}
|
||||||
|
r, err := k.ask("KY;")
|
||||||
|
if err != nil {
|
||||||
|
return // unsupported / timeout — send anyway, pacing covers it
|
||||||
|
}
|
||||||
|
if !kenwoodCWBufferFull(r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// kenwoodCWBufferFull reads the KY; status reply. Deliberately asymmetric: only a
|
||||||
|
// clear "1" after KY means full. Anything else reads as "go ahead" — refusing to
|
||||||
|
// send because a status line was phrased unexpectedly is the worse failure.
|
||||||
|
func kenwoodCWBufferFull(reply string) bool {
|
||||||
|
r := strings.TrimSpace(reply)
|
||||||
|
if !strings.HasPrefix(strings.ToUpper(r), "KY") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range r[2:] {
|
||||||
|
switch c {
|
||||||
|
case '0':
|
||||||
|
return false
|
||||||
|
case '1':
|
||||||
|
return true
|
||||||
|
case ' ', ';':
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopCW aborts the message being sent. Kenwood documents no KY buffer-clear, so
|
||||||
|
// this drops the transmitter — RX; forces receive, which is what an operator
|
||||||
|
// pressing Escape wants; anything still queued is not keyed on the air.
|
||||||
|
func (k *Kenwood) StopCW() error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
return k.write("RX;")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKeySpeed sets the keyer speed (WPM) via KS and remembers it for pacing.
|
||||||
|
func (k *Kenwood) SetKeySpeed(wpm int) error {
|
||||||
|
if wpm < 4 {
|
||||||
|
wpm = 4
|
||||||
|
}
|
||||||
|
if wpm > 99 {
|
||||||
|
wpm = 99 // KS is three digits but the K3 keyer tops out well below 100
|
||||||
|
}
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
k.keyWPM = wpm
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
return k.write(fmt.Sprintf("KS%03d;", wpm))
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyerWPM is the speed to pace the buffer by. The caller holds k.mu.
|
||||||
|
func (k *Kenwood) keyerWPM() int {
|
||||||
|
if k.keyWPM >= 4 {
|
||||||
|
return k.keyWPM
|
||||||
|
}
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// kenwoodCWDuration estimates how long a piece of text takes to key (PARIS
|
||||||
|
// timing: a character averages 10 dits, a dit is 1.2/wpm seconds).
|
||||||
|
func kenwoodCWDuration(text string, wpm int) time.Duration {
|
||||||
|
if wpm < 4 {
|
||||||
|
wpm = 20
|
||||||
|
}
|
||||||
|
ditMs := 1200.0 / float64(wpm)
|
||||||
|
return time.Duration(float64(len(text))*10*ditMs) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterKenwoodCW upper-cases and strips what the keyer cannot send; whitespace
|
||||||
|
// becomes a single word gap.
|
||||||
|
func filterKenwoodCW(text string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToUpper(text) {
|
||||||
|
if r == '\t' || r == '\n' || r == '\r' {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.ContainsRune(kenwoodCWAllowed, r) {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(strings.Fields(b.String()), " ")
|
||||||
|
}
|
||||||
@@ -265,8 +265,8 @@ func TestKenwoodSilentRigIsNotConnected(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("a silent port was reported as a connected rig")
|
t.Fatal("a silent port was reported as a connected rig")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "not answering") {
|
if !strings.Contains(err.Error(), "sent nothing") {
|
||||||
t.Errorf("error was %q — it should say the rig is not answering", err)
|
t.Errorf("error was %q — a silent port should be reported as silence", err)
|
||||||
}
|
}
|
||||||
if k.model != "" {
|
if k.model != "" {
|
||||||
t.Errorf("a stale model survived a failed connect: %q", k.model)
|
t.Errorf("a stale model survived a failed connect: %q", k.model)
|
||||||
@@ -300,6 +300,11 @@ func TestKenwoodSplitFromFRFT(t *testing.T) {
|
|||||||
|
|
||||||
// Split on, IF still silent about it: receive on A, transmit on B.
|
// Split on, IF still silent about it: receive on A, transmit on B.
|
||||||
rig.split = true
|
rig.split = true
|
||||||
|
// The FR/FT split re-check is throttled to once a second (it tripled the poll
|
||||||
|
// time on a slow K3), so back-to-back reads reuse the last result. Simulate the
|
||||||
|
// recheck window having elapsed — in the field the 250 ms poll covers it inside
|
||||||
|
// a second.
|
||||||
|
k.splitCheckAt = time.Time{}
|
||||||
if s, err = k.ReadState(); err != nil {
|
if s, err = k.ReadState(); err != nil {
|
||||||
t.Fatalf("read: %v", err)
|
t.Fatalf("read: %v", err)
|
||||||
}
|
}
|
||||||
@@ -355,3 +360,26 @@ func TestKenwoodSplitWhenFRFTRejected(t *testing.T) {
|
|||||||
t.Errorf("IF-reported split broke: split=%v tx=%d rx=%d", s.Split, s.FreqHz, s.RxFreqHz)
|
t.Errorf("IF-reported split broke: split=%v tx=%d rx=%d", s.Split, s.FreqHz, s.RxFreqHz)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A rig that talks but never answers what was asked must not be reported as a
|
||||||
|
// silent one.
|
||||||
|
//
|
||||||
|
// The two faults need opposite responses: silence means the radio is off, the
|
||||||
|
// wrong port, or a dead cable; noise means the baud rate is wrong or something
|
||||||
|
// is echoing the line. "The rig is not answering" sent an operator checking the
|
||||||
|
// power switch on a radio whose frames were visibly arriving.
|
||||||
|
func TestKenwoodNoisyRigIsReportedAsNoiseNotSilence(t *testing.T) {
|
||||||
|
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||||
|
k.dialPort = func() (serial.Port, error) {
|
||||||
|
return &fakeSerial{toRig: &strings.Builder{}, answer: func(cmd string) string {
|
||||||
|
return "XX9999;" // something, but never the reply to ID; or IF;
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
err := k.Connect()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a rig answering gibberish was reported as connected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "sending data") || !strings.Contains(err.Error(), "XX9999;") {
|
||||||
|
t.Errorf("error was %q — it should say data arrived, and quote it", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ func TestKenwoodModeDigit(t *testing.T) {
|
|||||||
{"RTTY", 14080000, '6'},
|
{"RTTY", 14080000, '6'},
|
||||||
{"AM", 7150000, '5'},
|
{"AM", 7150000, '5'},
|
||||||
{"FM", 145000000, '4'},
|
{"FM", 145000000, '4'},
|
||||||
{"FT8", 7074000, '1'}, // data rides on the sideband for the band
|
{"FT8", 7074000, '2'}, // data is ALWAYS USB, even below 10 MHz (K3 "DATA REV" otherwise)
|
||||||
{"FT8", 14074000, '2'},
|
{"FT8", 14074000, '2'}, // …and above
|
||||||
{"", 14074000, 0}, // nothing to set
|
{"", 14074000, 0}, // nothing to set
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
@@ -111,6 +111,23 @@ func TestKenwoodModeDigit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The DATA-mode preference only applies to soundcard/data modes, never to voice,
|
||||||
|
// CW or native RTTY — getting this wrong would hijack an SSB or CW mode change.
|
||||||
|
func TestIsKenwoodDataMode(t *testing.T) {
|
||||||
|
data := []string{"FT8", "FT4", "PSK31", "JT65", "DATA", "DIGITAL", "OLIVIA"}
|
||||||
|
notData := []string{"", "LSB", "USB", "SSB", "CW", "CW-R", "FM", "AM", "RTTY", "FSK", "RTTY-R"}
|
||||||
|
for _, m := range data {
|
||||||
|
if !isKenwoodDataMode(m) {
|
||||||
|
t.Errorf("isKenwoodDataMode(%q) = false, want true", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range notData {
|
||||||
|
if isKenwoodDataMode(m) {
|
||||||
|
t.Errorf("isKenwoodDataMode(%q) = true, want false", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// What the log records for each mode digit the rig reports.
|
// What the log records for each mode digit the rig reports.
|
||||||
func TestKenwoodModeToADIF(t *testing.T) {
|
func TestKenwoodModeToADIF(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
|
|||||||
+71
-6
@@ -28,6 +28,12 @@ type TCI struct {
|
|||||||
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
|
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
|
||||||
spotsEnabled bool // mirror cluster spots onto the TCI panorama
|
spotsEnabled bool // mirror cluster spots onto the TCI panorama
|
||||||
|
|
||||||
|
// OnSpotClick is called when the user clicks one of our spots on the TCI
|
||||||
|
// panorama (callsign + freq), so the host can fill the entry form. Set before
|
||||||
|
// Connect. Mirrors the FlexRadio panadapter-click flow.
|
||||||
|
OnSpotClick func(callsign string, freqHz int64)
|
||||||
|
unhandledSeen map[string]bool // log each unknown TCI message type once
|
||||||
|
|
||||||
mu sync.Mutex // guards conn + writes + state
|
mu sync.Mutex // guards conn + writes + state
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
|
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
|
||||||
@@ -110,15 +116,32 @@ func (t *TCI) SendSpot(s SpotInfo) error {
|
|||||||
if call == "" || s.FreqHz <= 0 {
|
if call == "" || s.FreqHz <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
color := strings.TrimSpace(s.Color)
|
// TCI's SPOT command wants the colour as a signed 32-bit DECIMAL integer in
|
||||||
if color == "" {
|
// 0xAARRGGBB order — NOT a "0x…" hex string (e.g. "spot:UN7GK,cw,14025000,
|
||||||
color = "#FFFFA500" // opaque orange default
|
// -16776961,test;"). ExpertSDR silently drops a spot whose colour field it
|
||||||
|
// can't parse as a number, which is why spots never showed on the panorama
|
||||||
|
// while tuning (a separate command) still worked.
|
||||||
|
hex := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(s.Color), "#"), "0x")
|
||||||
|
if hex == "" {
|
||||||
|
hex = "FFFFA500" // opaque orange default
|
||||||
|
}
|
||||||
|
if len(hex) == 6 {
|
||||||
|
hex = "FF" + hex // add full-opacity alpha when only RGB was supplied
|
||||||
|
}
|
||||||
|
argb, err := strconv.ParseUint(hex, 16, 32)
|
||||||
|
if err != nil {
|
||||||
|
argb = 0xFFFFA500
|
||||||
|
}
|
||||||
|
// Use a valid TCI modulation (usb/lsb/cw/digl…) so ExpertSDR accepts the spot;
|
||||||
|
// fall back to the raw label if we can't map it. The click-to-tune path already
|
||||||
|
// maps the mode separately, so this only affects the spot's displayed mode.
|
||||||
|
mode := adifToTCIMode(s.Mode, s.FreqHz)
|
||||||
|
if mode == "" {
|
||||||
|
mode = strings.ToLower(strings.TrimSpace(s.Mode))
|
||||||
}
|
}
|
||||||
color = "0x" + strings.TrimPrefix(color, "#")
|
|
||||||
mode := strings.ToLower(strings.TrimSpace(s.Mode))
|
|
||||||
// Commas/semicolons would break TCI's comma-separated argument parsing.
|
// Commas/semicolons would break TCI's comma-separated argument parsing.
|
||||||
text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
|
text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
|
||||||
return t.send(fmt.Sprintf("spot:%s,%s,%d,%s,%s;", call, mode, s.FreqHz, color, text))
|
return t.send(fmt.Sprintf("spot:%s,%s,%d,%d,%s;", call, mode, s.FreqHz, int32(argb), text))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disconnect closes the WebSocket; the reader goroutine then exits.
|
// Disconnect closes the WebSocket; the reader goroutine then exits.
|
||||||
@@ -294,6 +317,48 @@ func (t *TCI) handle(msg string) {
|
|||||||
if get(0) == "0" {
|
if get(0) == "0" {
|
||||||
t.tx = get(1) == "true"
|
t.tx = get(1) == "true"
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
lname := strings.ToLower(name)
|
||||||
|
// A click on one of our panorama spots comes back as
|
||||||
|
// CLICKED_ON_SPOT:<call>,<hz> (legacy)
|
||||||
|
// RX_CLICKED_ON_SPOT:<rx>,<ch>,<call>,<hz>
|
||||||
|
// Neither name starts with "spot", which is why the click was silently
|
||||||
|
// ignored before. Read the callsign (the one non-numeric field) and the
|
||||||
|
// frequency (the large numeric field) positionally-independently, so both
|
||||||
|
// shapes work without depending on the exact arg order.
|
||||||
|
if strings.Contains(lname, "spot") {
|
||||||
|
var call string
|
||||||
|
var hz int64
|
||||||
|
for _, raw := range f {
|
||||||
|
v := strings.TrimSpace(raw)
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
|
if n >= 10000 { // a real frequency, not an rx/channel index
|
||||||
|
hz = n
|
||||||
|
}
|
||||||
|
} else if call == "" {
|
||||||
|
call = strings.ToUpper(v) // callsigns always carry letters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debugLog.Printf("TCI: spot click %q → call=%s freq=%d", msg, call, hz)
|
||||||
|
if call != "" && t.OnSpotClick != nil {
|
||||||
|
cb := t.OnSpotClick
|
||||||
|
go cb(call, hz)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Log every OTHER unknown message TYPE once, so the protocol (incl. any
|
||||||
|
// spot-click notification named differently) is discoverable from the log
|
||||||
|
// without flooding it with the frequent streamed messages.
|
||||||
|
if t.unhandledSeen == nil {
|
||||||
|
t.unhandledSeen = map[string]bool{}
|
||||||
|
}
|
||||||
|
if !t.unhandledSeen[lname] {
|
||||||
|
t.unhandledSeen[lname] = true
|
||||||
|
debugLog.Printf("TCI: (unhandled once) %s", msg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-17
@@ -82,9 +82,12 @@ var yaesuModeToADIF = map[byte]string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Yaesu struct {
|
type Yaesu struct {
|
||||||
portName string
|
// lowerLines deasserts DTR and RTS on connect — see Kenwood.lowerLines for
|
||||||
baud int
|
// why neither default is safe for every station.
|
||||||
digital string // ADIF mode reported for DATA (FT8, RTTY…)
|
lowerLines bool
|
||||||
|
portName string
|
||||||
|
baud int
|
||||||
|
digital string // ADIF mode reported for DATA (FT8, RTTY…)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
port serial.Port
|
port serial.Port
|
||||||
@@ -127,6 +130,14 @@ func NewYaesu(portName string, baud int, digital string) *Yaesu {
|
|||||||
return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
|
return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set
|
||||||
|
// before Connect.
|
||||||
|
func (y *Yaesu) SetLowerLines(v bool) {
|
||||||
|
y.mu.Lock()
|
||||||
|
y.lowerLines = v
|
||||||
|
y.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func (y *Yaesu) Name() string { return "yaesu" }
|
func (y *Yaesu) Name() string { return "yaesu" }
|
||||||
|
|
||||||
func (y *Yaesu) Connect() error {
|
func (y *Yaesu) Connect() error {
|
||||||
@@ -150,20 +161,12 @@ func (y *Yaesu) Connect() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
|
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
|
||||||
}
|
}
|
||||||
// Deassert DTR and RTS.
|
// The modem lines are only touched when the operator asks for it — see the
|
||||||
//
|
// note on Kenwood.lowerLines. Both defaults break somebody's station.
|
||||||
// Windows raises both when a serial port is opened, and a great many
|
if y.lowerLines {
|
||||||
// interfaces read them as PTT: a Xiegu G90 behind a DE-19 goes into
|
_ = p.SetDTR(false)
|
||||||
// transmit the moment OpsLog connects and STAYS there — Xiegu's own
|
_ = p.SetRTS(false)
|
||||||
// documentation asks for RTS and DTR low. The same applies to an Icom with
|
}
|
||||||
// USB SEND mapped to a line (which is why the CI-V backend has done this
|
|
||||||
// from the start) and to any rig keyed by a home-made cable.
|
|
||||||
//
|
|
||||||
// PTT on this backend is a CAT command, so neither line should ever be
|
|
||||||
// asserted here. A station keying by RTS/DTR configures that separately,
|
|
||||||
// on its own port.
|
|
||||||
_ = p.SetDTR(false)
|
|
||||||
_ = p.SetRTS(false)
|
|
||||||
p.SetReadTimeout(300 * time.Millisecond)
|
p.SetReadTimeout(300 * time.Millisecond)
|
||||||
y.port = p
|
y.port = p
|
||||||
|
|
||||||
|
|||||||
@@ -648,6 +648,18 @@ func parseShowDX(line string) (Spot, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseSpot(line string) (Spot, bool) {
|
func parseSpot(line string) (Spot, bool) {
|
||||||
|
// A cluster prints its prompt WITHOUT a trailing newline, so the first spot
|
||||||
|
// to arrive gets glued onto it and the line reads
|
||||||
|
// "login: DX de YO2CK-#: 21074.0 PY4PTO FT8 …". The anchor then fails
|
||||||
|
// and the spot is dropped — silently, apart from an UNPARSED log line.
|
||||||
|
//
|
||||||
|
// Cutting everything ahead of "DX de" rather than un-anchoring the pattern:
|
||||||
|
// an un-anchored match would also accept a talk/announce line that merely
|
||||||
|
// QUOTES a spot, and start injecting other people's conversations as real
|
||||||
|
// spots.
|
||||||
|
if i := strings.Index(line, "DX de"); i > 0 {
|
||||||
|
line = line[i:]
|
||||||
|
}
|
||||||
m := spotRE.FindStringSubmatch(line)
|
m := spotRE.FindStringSubmatch(line)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return Spot{}, false
|
return Spot{}, false
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ func TestParseSpot(t *testing.T) {
|
|||||||
`DX de DK0SWL: 14195.5 W1AW CQ Field Day 1745Z`,
|
`DX de DK0SWL: 14195.5 W1AW CQ Field Day 1745Z`,
|
||||||
"W1AW", 14195.5, "20m", "",
|
"W1AW", 14195.5, "20m", "",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// The cluster prints its prompt with no trailing newline, so the
|
||||||
|
// first spot arrives glued to it. Seen live on 2026-08-02.
|
||||||
|
`login: DX de YO2CK-#: 21074.0 PY4PTO FT8 -16 dB 1733Z`,
|
||||||
|
"PY4PTO", 21074.0, "15m", "",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
`DX de F4XYZ-#: 7074.0 3DA0RU FT8 -10 0823Z JN18`,
|
`DX de F4XYZ-#: 7074.0 3DA0RU FT8 -10 0823Z JN18`,
|
||||||
"3DA0RU", 7074.0, "40m", "JN18",
|
"3DA0RU", 7074.0, "40m", "JN18",
|
||||||
|
|||||||
@@ -86,7 +86,13 @@ type Server struct {
|
|||||||
stopped bool
|
stopped bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|
||||||
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
|
// WSJT: dial frequency from the last Status, added to Decode audio offsets —
|
||||||
|
// keyed by the WSJT-X instance id, NOT one value per listener. Two instances
|
||||||
|
// share one multicast group, so a single value was overwritten by whichever
|
||||||
|
// sent Status last: decodes from the 50.313 receiver were placed on the
|
||||||
|
// 50.400 panadapter. WSJT-X requires --rig-name for a second instance, so the
|
||||||
|
// id is distinct whenever there is more than one.
|
||||||
|
dialHz map[string]int64
|
||||||
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
|
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +202,6 @@ func (s *Server) run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||||
applog.Printf("udp: [%s] rx %d bytes from %s\n", s.cfg.Name, len(pkt), remote)
|
|
||||||
ev := Event{ConfigID: s.cfg.ID, Service: s.cfg.ServiceType, Source: remote.String()}
|
ev := Event{ConfigID: s.cfg.ID, Service: s.cfg.ServiceType, Source: remote.String()}
|
||||||
switch s.cfg.ServiceType {
|
switch s.cfg.ServiceType {
|
||||||
case ServiceWSJT:
|
case ServiceWSJT:
|
||||||
@@ -212,15 +217,21 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
// offsets can be turned into RF frequencies for the panadapter.
|
// offsets can be turned into RF frequencies for the panadapter.
|
||||||
if w.FreqHz > 0 && !w.IsDecode {
|
if w.FreqHz > 0 && !w.IsDecode {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.lastDialHz = w.FreqHz
|
if s.dialHz == nil {
|
||||||
|
s.dialHz = map[string]int64{}
|
||||||
|
}
|
||||||
|
s.dialHz[w.ProgramID] = w.FreqHz
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
if w.IsDecode {
|
if w.IsDecode {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
dial := s.lastDialHz
|
dial := s.dialHz[w.ProgramID]
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if dial <= 0 {
|
if dial <= 0 {
|
||||||
return // no dial freq yet (Status not seen) → can't place the spot
|
// No Status from THIS instance yet. Guessing with another
|
||||||
|
// instance's dial is what produced the wrong-panadapter spots,
|
||||||
|
// so drop the decode and wait — Status arrives every second.
|
||||||
|
return
|
||||||
}
|
}
|
||||||
ev.DecodeCall = w.DecodeCall
|
ev.DecodeCall = w.DecodeCall
|
||||||
ev.DecodeFreqHz = dial + w.DeltaFreqHz
|
ev.DecodeFreqHz = dial + w.DeltaFreqHz
|
||||||
@@ -229,8 +240,12 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
ev.Mode = w.Mode
|
ev.Mode = w.Mode
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
|
// Only a logged QSO is worth a line — WSJT-X/MSHV send a Status packet
|
||||||
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
|
// every second, and logging each one buried the rest of the file.
|
||||||
|
if len(w.LoggedADIF) > 0 {
|
||||||
|
applog.Printf("udp: [%s] WSJT QSO logged: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
|
||||||
|
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
|
||||||
|
}
|
||||||
ev.DXCall = w.DXCall
|
ev.DXCall = w.DXCall
|
||||||
ev.DXGrid = w.DXGrid
|
ev.DXGrid = w.DXGrid
|
||||||
ev.Mode = w.Mode
|
ev.Mode = w.Mode
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# QRZ.com XML API — what a free account returns, and what a subscription adds
|
||||||
|
|
||||||
|
Captured 2026-08-02 on F4BPO's two accounts, same callsign looked up twice
|
||||||
|
within a minute. Kept because the difference is the answer to a question that
|
||||||
|
comes back regularly: *"someone told me their logger gets the 6-character
|
||||||
|
locator from QRZ without paying"*. It does not.
|
||||||
|
|
||||||
|
## Free account — `SubExp: non-subscriber`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Callsign>
|
||||||
|
<call>F4BPO</call>
|
||||||
|
<fname>Gregory</fname>
|
||||||
|
<name>Salaun</name>
|
||||||
|
<addr2>Veigy-Foncenex</addr2>
|
||||||
|
<country>France</country>
|
||||||
|
</Callsign>
|
||||||
|
<Session>
|
||||||
|
<SubExp>non-subscriber</SubExp>
|
||||||
|
<Message>A subscription is required to access the complete record.</Message>
|
||||||
|
</Session>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Five fields.** No `grid`, no `lat`/`lon`, no `dxcc`, no zones, no email, no
|
||||||
|
street, no image.
|
||||||
|
|
||||||
|
## Subscriber — `SubExp: Sat Jul 3 21:45:38 2027`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Callsign>
|
||||||
|
<call>F4BPO</call> <dxcc>227</dxcc> <nickname>Greg</nickname>
|
||||||
|
<fname>Gregory</fname> <name>Salaun</name>
|
||||||
|
<addr2>Veigy-Foncenex</addr2><zip>74140</zip> <country>France</country>
|
||||||
|
<lat>46.103333</lat> <lon>6.290000</lon> <grid>JN36dc</grid>
|
||||||
|
<ccode>97</ccode> <land>France</land> <class>2</class>
|
||||||
|
<codes>E</codes> <qslmgr>Lotw, Buro, Direct</qslmgr>
|
||||||
|
<email>…</email> <u_views>56966</u_views>
|
||||||
|
<bio>4414</bio> <biodate>2026-07-08 15:30:58</biodate>
|
||||||
|
<image>https://cdn-xml.qrz.com/o/f4bpo/F4BPO_Greg_png.jpg</image>
|
||||||
|
<imageinfo>800:1200:221041</imageinfo>
|
||||||
|
<moddate>2025-08-24 19:55:31</moddate>
|
||||||
|
<eqsl>0</eqsl> <mqsl>1</mqsl> <lotw>1</lotw>
|
||||||
|
<cqzone>14</cqzone> <ituzone>27</ituzone>
|
||||||
|
<geoloc>user</geoloc> <name_fmt>Gregory "Greg" Salaun</name_fmt>
|
||||||
|
<serial>1000134</serial>
|
||||||
|
</Callsign>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **A free QRZ account can never fill the locator, the coordinates, the zones
|
||||||
|
or the e-mail.** If someone reports a logger doing it on a free account, they
|
||||||
|
are either using HamQTH (free, returns a 6-character `grid`) or they hold an
|
||||||
|
XML subscription without realising it — the QRZ.com account and the "XML
|
||||||
|
Logbook Data" subscription are sold separately. `SubExp` in the session block
|
||||||
|
settles it in one request.
|
||||||
|
- **Profile edits take a few minutes to reach the XML feed.** Changing the grid
|
||||||
|
on the website and looking the callsign up immediately returns the OLD value,
|
||||||
|
which looks exactly like a stale cache. Re-test after a few minutes before
|
||||||
|
suspecting the logger. (This cost an hour on 2026-08-02.)
|
||||||
|
|
||||||
|
## Fields OpsLog does not use yet
|
||||||
|
|
||||||
|
`qrz.go` maps call, name, addr1/2, zip, state, county, country, grid, lat, lon,
|
||||||
|
dxcc, cqzone, ituzone, cont, email, qslmgr and image. Left on the table:
|
||||||
|
|
||||||
|
- **`geoloc`** — `user` when the operator entered the position by hand, versus a
|
||||||
|
geocoded value derived from the postal address. This is a direct statement of
|
||||||
|
how much the coordinates and the locator can be trusted, and it is exactly
|
||||||
|
the question the grid-precision rule in App.tsx tries to answer by comparing
|
||||||
|
string lengths. Worth wiring if the locator ever misbehaves again.
|
||||||
|
- **`eqsl` / `mqsl` / `lotw`** — the station's QSL preferences as flags, more
|
||||||
|
reliable than parsing the free-text `qslmgr`.
|
||||||
|
- `nickname` / `name_fmt`, `class`, `moddate`, `bio`.
|
||||||
@@ -29,6 +29,13 @@ type Info struct {
|
|||||||
IsUser bool `json:"is_user"`
|
IsUser bool `json:"is_user"`
|
||||||
LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD
|
LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD
|
||||||
DaysAgo int `json:"days_ago"` // days since last upload (-1 if unknown)
|
DaysAgo int `json:"days_ago"` // days since last upload (-1 if unknown)
|
||||||
|
// FeedDate is the newest upload date anywhere in ARRL's file, i.e. when ARRL
|
||||||
|
// last regenerated it, and FeedStale says that date is more than two days old.
|
||||||
|
// Without them "last uploaded 7 days ago" is unreadable: it may mean the
|
||||||
|
// station went quiet, or that the FEED has been frozen for a week — which is
|
||||||
|
// exactly what ARRL served on 2026-08-02, its newest line dated 2026-07-27.
|
||||||
|
FeedDate string `json:"feed_date,omitempty"`
|
||||||
|
FeedStale bool `json:"feed_stale"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager holds the parsed list + cache location.
|
// Manager holds the parsed list + cache location.
|
||||||
@@ -36,6 +43,7 @@ type Manager struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
users map[string]time.Time // UPPER(callsign) → last-upload date (UTC)
|
users map[string]time.Time // UPPER(callsign) → last-upload date (UTC)
|
||||||
updated time.Time // when the cache was last refreshed
|
updated time.Time // when the cache was last refreshed
|
||||||
|
newest time.Time // newest upload date in the file = when ARRL built it
|
||||||
dir string
|
dir string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
@@ -100,6 +108,7 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
|
|||||||
// parse loads the CSV bytes into the map and returns the count.
|
// parse loads the CSV bytes into the map and returns the count.
|
||||||
func (m *Manager) parse(data []byte) int {
|
func (m *Manager) parse(data []byte) int {
|
||||||
users := make(map[string]time.Time, 1<<20)
|
users := make(map[string]time.Time, 1<<20)
|
||||||
|
var newest time.Time
|
||||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||||
for sc.Scan() {
|
for sc.Scan() {
|
||||||
@@ -120,16 +129,29 @@ func (m *Manager) parse(data []byte) int {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
users[call] = t
|
users[call] = t
|
||||||
|
if t.After(newest) {
|
||||||
|
newest = t
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(users) == 0 {
|
if len(users) == 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.users = users
|
m.users = users
|
||||||
|
m.newest = newest
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
return len(users)
|
return len(users)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// feedInfo fills the feed-age fields shared by every Lookup result.
|
||||||
|
func (m *Manager) feedInfo(i Info) Info {
|
||||||
|
if !m.newest.IsZero() {
|
||||||
|
i.FeedDate = m.newest.Format("2006-01-02")
|
||||||
|
i.FeedStale = time.Since(m.newest) > 48*time.Hour
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
// Lookup reports whether callsign is a LoTW user and how long ago it uploaded.
|
// Lookup reports whether callsign is a LoTW user and how long ago it uploaded.
|
||||||
// Tries the exact call, then (for portable calls like "EA8/DL1ABC" or "F5ABC/P")
|
// Tries the exact call, then (for portable calls like "EA8/DL1ABC" or "F5ABC/P")
|
||||||
// the longest slash-separated segment — the base call LoTW is keyed on.
|
// the longest slash-separated segment — the base call LoTW is keyed on.
|
||||||
@@ -154,13 +176,13 @@ func (m *Manager) Lookup(callsign string) Info {
|
|||||||
t, ok = m.users[base]
|
t, ok = m.users[base]
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return Info{DaysAgo: -1}
|
return m.feedInfo(Info{DaysAgo: -1})
|
||||||
}
|
}
|
||||||
days := int(time.Since(t).Hours() / 24)
|
days := int(time.Since(t).Hours() / 24)
|
||||||
if days < 0 {
|
if days < 0 {
|
||||||
days = 0
|
days = 0
|
||||||
}
|
}
|
||||||
return Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days}
|
return m.feedInfo(Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count returns how many callsigns are loaded.
|
// Count returns how many callsigns are loaded.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ package powergenius
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -23,7 +24,13 @@ const (
|
|||||||
defaultPort = 9008
|
defaultPort = 9008
|
||||||
dialTimeout = 5 * time.Second
|
dialTimeout = 5 * time.Second
|
||||||
ioTimeout = 3 * time.Second
|
ioTimeout = 3 * time.Second
|
||||||
pollEvery = 1500 * time.Millisecond
|
// Poll fast enough that the amp's OWN forward/current figures make a usable
|
||||||
|
// live meter on their own — the UI prefers them over the FlexRadio VITA stream
|
||||||
|
// (which never traverses a public-IP/NAT link), so this direct reading is what
|
||||||
|
// an operator watches when running the amp over the internet. At 250 ms the
|
||||||
|
// SSB envelope is sampled often enough that peak-hold keeps a steady reading
|
||||||
|
// instead of collapsing to ~1 W in the gaps between syllables.
|
||||||
|
pollEvery = 250 * time.Millisecond
|
||||||
reconnectDelay = 2 * time.Second
|
reconnectDelay = 2 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,11 +43,22 @@ type Status struct {
|
|||||||
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
|
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
|
||||||
Temperature float64 `json:"temperature"`
|
Temperature float64 `json:"temperature"`
|
||||||
Operate bool `json:"operate"` // OPERATE vs STANDBY (optimistic until the amp reports it)
|
Operate bool `json:"operate"` // OPERATE vs STANDBY (optimistic until the amp reports it)
|
||||||
|
|
||||||
|
// Live power, read straight from the amplifier's own status frame rather
|
||||||
|
// than sampled off the FlexRadio meter stream. PeakW is the amp's own peak
|
||||||
|
// detector: a poll catches one instant of the envelope, so the plain forward
|
||||||
|
// figure lands between syllables as often as on a peak.
|
||||||
|
FwdW float64 `json:"fwd_w"` // forward power [W] (the frame reports dBm)
|
||||||
|
PeakW float64 `json:"peak_w"` // peak forward power [W]
|
||||||
|
Vswr float64 `json:"vswr"` // VSWR, derived from the frame's return loss in dB
|
||||||
|
Id float64 `json:"id"` // drain current [A]
|
||||||
|
PeakId float64 `json:"peak_id"` // peak drain current [A]
|
||||||
}
|
}
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
host string
|
host string
|
||||||
port int
|
port int
|
||||||
|
password string // remote-access code; sent as "auth <code>" when the banner announces AUTH
|
||||||
|
|
||||||
mu sync.Mutex // serialises command send/recv on the connection
|
mu sync.Mutex // serialises command send/recv on the connection
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
@@ -59,11 +77,11 @@ type Client struct {
|
|||||||
running bool
|
running bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(host string, port int) *Client {
|
func New(host string, port int, password string) *Client {
|
||||||
if port <= 0 || port > 65535 {
|
if port <= 0 || port > 65535 {
|
||||||
port = defaultPort
|
port = defaultPort
|
||||||
}
|
}
|
||||||
return &Client{host: host, port: port, stop: make(chan struct{}), status: Status{Host: host}}
|
return &Client{host: host, port: port, password: strings.TrimSpace(password), stop: make(chan struct{}), status: Status{Host: host}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Start() error {
|
func (c *Client) Start() error {
|
||||||
@@ -107,9 +125,15 @@ func (c *Client) SetFanMode(mode string) error {
|
|||||||
default:
|
default:
|
||||||
return fmt.Errorf("powergenius: invalid fan mode %q", mode)
|
return fmt.Errorf("powergenius: invalid fan mode %q", mode)
|
||||||
}
|
}
|
||||||
if _, err := c.command("setup fanmode=" + m); err != nil {
|
// Set with the bare "key=value" verb the amp uses for its own status fields
|
||||||
|
// (same convention as "operate=1"). The earlier "setup fanmode=…" carried a
|
||||||
|
// bogus prefix the amp silently ignored, so the fan never changed and the next
|
||||||
|
// status kept reporting the old mode — the revert-to-Contest the operator saw.
|
||||||
|
reply, err := c.command("fanmode=" + m)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
applog.Printf("pgxl: set fanmode=%s reply=%q", m, reply)
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.status.FanMode = m // optimistic
|
c.status.FanMode = m // optimistic
|
||||||
c.fanPending, c.fanPendingAt = m, time.Now()
|
c.fanPending, c.fanPendingAt = m, time.Now()
|
||||||
@@ -166,12 +190,62 @@ func (c *Client) ensureConnected() error {
|
|||||||
}
|
}
|
||||||
c.conn = conn
|
c.conn = conn
|
||||||
c.reader = bufio.NewReader(conn)
|
c.reader = bufio.NewReader(conn)
|
||||||
// Discard the version banner the device sends on connect.
|
// Banner: "V…" (LAN) or "V… AUTH" (remote → authentication required, exactly
|
||||||
|
// like the Tuner Genius / Antenna Genius). Send the remote code when the amp
|
||||||
|
// demands it, otherwise every command comes back "Unauthorized".
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||||
_, _ = c.reader.ReadString('\n')
|
banner, _ := c.reader.ReadString('\n')
|
||||||
|
banner = strings.TrimSpace(banner)
|
||||||
|
applog.Printf("pgxl: connected %s → %s, banner=%q", conn.LocalAddr(), conn.RemoteAddr(), banner)
|
||||||
|
if strings.Contains(banner, "AUTH") {
|
||||||
|
if c.password == "" {
|
||||||
|
applog.Printf("pgxl: device requires AUTH but no remote code set (Settings → Amplifier)")
|
||||||
|
} else if err := c.authLocked(); err != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn, c.reader = nil, nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authLocked authenticates the remote link. Must be called with c.mu held
|
||||||
|
// (during ensureConnected). 4O3A boxes want "auth code=<pw>" and reply
|
||||||
|
// "R<seq>|<hex>|" with an EMPTY message — hex "0" means accepted, so the response
|
||||||
|
// CODE decides, not the text. The device also rejects the FIRST attempt (R|FF)
|
||||||
|
// and accepts a retry, so resend a few times before giving up.
|
||||||
|
func (c *Client) authLocked() error {
|
||||||
|
var lastHex string
|
||||||
|
for try := 1; try <= 4; try++ {
|
||||||
|
id := c.cmdID.Add(1)
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
|
||||||
|
if _, err := fmt.Fprintf(c.conn, "C%d|auth code=%s\n", id, c.password); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||||
|
line, err := c.reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
applog.Printf("pgxl: auth reply=%q (try %d)", line, try)
|
||||||
|
hex, msg := "", ""
|
||||||
|
if p := strings.SplitN(line, "|", 3); len(p) >= 2 {
|
||||||
|
hex = strings.TrimSpace(p[1])
|
||||||
|
if len(p) == 3 {
|
||||||
|
msg = strings.TrimSpace(p[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// hex "0" is the standard accept; also treat an explicit OK message as success.
|
||||||
|
if hex == "0" || (msg != "" && strings.Contains(strings.ToLower(msg), "ok")) {
|
||||||
|
applog.Printf("pgxl: authenticated")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastHex = hex
|
||||||
|
}
|
||||||
|
return fmt.Errorf("powergenius: authentication failed after 4 tries (R|%s|) — check the remote code", lastHex)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) dropConn() {
|
func (c *Client) dropConn() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
if c.conn != nil {
|
if c.conn != nil {
|
||||||
@@ -226,9 +300,10 @@ func (c *Client) parse(resp string) {
|
|||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.status.Connected = true
|
c.status.Connected = true
|
||||||
c.status.LastError = ""
|
c.status.LastError = ""
|
||||||
// Log each DISTINCT status payload once: the PGXL's field set isn't fully
|
// Log the first REAL status frame (one that carries "key=value" fields) so the
|
||||||
// documented, so this is how we learn the real key for e.g. operate/standby.
|
// field set is visible — even if an earlier reply was junk like "Unauthorized"
|
||||||
if data != c.lastRaw {
|
// (which would otherwise latch lastRaw and hide the real frame).
|
||||||
|
if strings.Contains(data, "=") && !strings.Contains(c.lastRaw, "=") {
|
||||||
c.lastRaw = data
|
c.lastRaw = data
|
||||||
applog.Printf("pgxl: status raw=%q", data)
|
applog.Printf("pgxl: status raw=%q", data)
|
||||||
}
|
}
|
||||||
@@ -252,7 +327,50 @@ func (c *Client) parse(resp string) {
|
|||||||
c.status.FanMode = dev
|
c.status.FanMode = dev
|
||||||
case "temp":
|
case "temp":
|
||||||
c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64)
|
c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64)
|
||||||
|
case "fwd":
|
||||||
|
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
|
||||||
|
c.status.FwdW = dbmToWatts(v)
|
||||||
|
}
|
||||||
|
case "peakfwd":
|
||||||
|
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
|
||||||
|
c.status.PeakW = dbmToWatts(v)
|
||||||
|
}
|
||||||
|
case "swr":
|
||||||
|
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
|
||||||
|
c.status.Vswr = returnLossToVswr(v)
|
||||||
|
}
|
||||||
|
case "id":
|
||||||
|
c.status.Id, _ = strconv.ParseFloat(kv[1], 64)
|
||||||
|
case "peakid":
|
||||||
|
c.status.PeakId, _ = strconv.ParseFloat(kv[1], 64)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.statusMu.Unlock()
|
c.statusMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dbmToWatts converts a power reading in dBm to watts (0 dBm = 1 mW). The amp
|
||||||
|
// reports power that way — "fwd=60.5" is 1122 W, not 60 W.
|
||||||
|
func dbmToWatts(dbm float64) float64 {
|
||||||
|
if dbm <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return math.Pow(10, (dbm-30)/10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// returnLossToVswr converts the amp's "swr" field — a return loss in dB, sent
|
||||||
|
// negative for a good match — into the VSWR ratio an operator reads.
|
||||||
|
func returnLossToVswr(swrDb float64) float64 {
|
||||||
|
rl := math.Abs(swrDb)
|
||||||
|
if rl <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
rho := math.Pow(10, -rl/20)
|
||||||
|
if rho >= 1 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
vswr := (1 + rho) / (1 - rho)
|
||||||
|
if vswr > 99.9 || math.IsInf(vswr, 0) || math.IsNaN(vswr) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return vswr
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
// - KMTronic LAN 8-relay WEB board — 8 relays. Control: GET /FF{rr}{ss}
|
// - KMTronic LAN 8-relay WEB board — 8 relays. Control: GET /FF{rr}{ss}
|
||||||
// (rr = 01..08, ss = 01 on / 00 off); status: GET /status.xml with
|
// (rr = 01..08, ss = 01 on / 00 off); status: GET /status.xml with
|
||||||
// <relay1>..<relay8> (relay0 is reserved). Optional HTTP basic auth.
|
// <relay1>..<relay8> (relay0 is reserved). Optional HTTP basic auth.
|
||||||
|
// - Dingtian IOT relay (DTWONDER) — 2/4/8/16/24/32 relays over its HTTP GET
|
||||||
|
// CGI. Control: GET /relay_cgi.cgi?type=0&relay=N&on=1&time=0&pwd=P&;
|
||||||
|
// status: GET /relay_cgi_load.cgi. Both answer &-separated fields.
|
||||||
|
// (IOT Relay Programming Manual V1.9.8.1, §3.)
|
||||||
//
|
//
|
||||||
// A Device presents the same surface to the app regardless of wire protocol.
|
// A Device presents the same surface to the app regardless of wire protocol.
|
||||||
package relaydev
|
package relaydev
|
||||||
@@ -173,3 +177,119 @@ func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dingtian IOT relay (DTWONDER) ──────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The board speaks several protocols (Modbus, MQTT, CoAP, its own binary); we
|
||||||
|
// use the HTTP GET CGI, which needs no connection state and matches how the
|
||||||
|
// other network boards here are driven.
|
||||||
|
//
|
||||||
|
// Both endpoints answer &-separated fields, first one 0 on success:
|
||||||
|
//
|
||||||
|
// /relay_cgi_load.cgi → &0&4&1&0&1&0&
|
||||||
|
// result, count, r1…rN
|
||||||
|
// /relay_cgi.cgi?type=0&relay=0&on=1&time=0&pwd=0& → &0&0&0&1&0&
|
||||||
|
// result, type, relay, on, time
|
||||||
|
//
|
||||||
|
// NOTE the relay index in the URL is ZERO-based (relay=0 is relay 1), while the
|
||||||
|
// Device interface is 1-based like every other board here.
|
||||||
|
type dingtian struct {
|
||||||
|
host string
|
||||||
|
session string // optional: the board can require "Cookie: session=<id>"
|
||||||
|
pwd string // CGI password, 0–9999; "0" (or blank) when none is set
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDingtian builds a Dingtian IOT relay client. session is the HTTP session ID
|
||||||
|
// when the board has "HTTP Session" enabled (blank otherwise); pwd is its relay
|
||||||
|
// password (blank or "0" when none).
|
||||||
|
func NewDingtian(host, session, pwd string, count int) Device {
|
||||||
|
if count <= 0 {
|
||||||
|
count = 2
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(pwd) == "" {
|
||||||
|
pwd = "0"
|
||||||
|
}
|
||||||
|
return &dingtian{host: host, session: strings.TrimSpace(session), pwd: strings.TrimSpace(pwd), count: count}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *dingtian) Count() int { return d.count }
|
||||||
|
func (d *dingtian) Close() error { return nil } // stateless HTTP, nothing to release
|
||||||
|
|
||||||
|
// getCGI issues the GET with the session cookie the board may require.
|
||||||
|
func (d *dingtian) getCGI(ctx context.Context, url string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if d.session != "" {
|
||||||
|
req.Header.Set("Cookie", "session="+d.session)
|
||||||
|
}
|
||||||
|
resp, err := httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dtFields splits an &-separated CGI reply into its fields, dropping the empty
|
||||||
|
// ones the leading and trailing "&" produce.
|
||||||
|
func dtFields(body []byte) []string {
|
||||||
|
var out []string
|
||||||
|
for _, f := range strings.Split(strings.TrimSpace(string(body)), "&") {
|
||||||
|
if f = strings.TrimSpace(f); f != "" {
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *dingtian) Set(ctx context.Context, relay int, on bool) error {
|
||||||
|
if relay < 1 || relay > d.count {
|
||||||
|
return fmt.Errorf("relay %d out of range 1..%d", relay, d.count)
|
||||||
|
}
|
||||||
|
state := 0
|
||||||
|
if on {
|
||||||
|
state = 1
|
||||||
|
}
|
||||||
|
// type=0 is plain ON/OFF (1 = jogging, 2 = delay, 3 = flash, 4 = toggle),
|
||||||
|
// and time is then unused.
|
||||||
|
body, err := d.getCGI(ctx, fmt.Sprintf("http://%s/relay_cgi.cgi?type=0&relay=%d&on=%d&time=0&pwd=%s&",
|
||||||
|
d.host, relay-1, state, d.pwd))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A wrong password or session answers 200 with a non-zero result (e.g.
|
||||||
|
// "&302&/&"), so the HTTP status alone does not tell us it worked.
|
||||||
|
if f := dtFields(body); len(f) == 0 || f[0] != "0" {
|
||||||
|
return fmt.Errorf("dingtian: refused (%s) — check the relay password and the HTTP session ID",
|
||||||
|
strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *dingtian) Status(ctx context.Context) ([]bool, error) {
|
||||||
|
body, err := d.getCGI(ctx, fmt.Sprintf("http://%s/relay_cgi_load.cgi", d.host))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f := dtFields(body)
|
||||||
|
if len(f) < 2 || f[0] != "0" {
|
||||||
|
return nil, fmt.Errorf("dingtian: bad status reply %q", strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
// The board reports its own relay count; trust it over the configured one so
|
||||||
|
// a mis-set channel count doesn't silently hide relays.
|
||||||
|
if n, e := strconv.Atoi(f[1]); e == nil && n > 0 && n <= 32 {
|
||||||
|
d.count = n
|
||||||
|
}
|
||||||
|
out := make([]bool, d.count)
|
||||||
|
for i := 0; i < d.count && i+2 < len(f); i++ {
|
||||||
|
out[i] = f[i+2] == "1"
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,3 +99,76 @@ func TestKMTronicSetURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_ = d.Set(context.Background(), 1, false) // → /FF0100
|
_ = d.Set(context.Background(), 1, false) // → /FF0100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dingtian — the wire examples come straight from the IOT Relay Programming
|
||||||
|
// Manual V1.9.8.1 §3.1/§3.2. The two traps pinned here: the URL relay index is
|
||||||
|
// ZERO-based while the Device interface is 1-based, and a refusal (bad password
|
||||||
|
// or session) answers HTTP 200 with a non-zero first field.
|
||||||
|
|
||||||
|
func TestDingtianStatus(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/relay_cgi_load.cgi" {
|
||||||
|
t.Errorf("unexpected status path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
// Manual's own example: ok, 4 relays, 1 on, 2 off, 3 on, 4 off.
|
||||||
|
_, _ = w.Write([]byte("&0&4&1&0&1&0&"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "", "", 2)
|
||||||
|
st, err := d.Status(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The board said 4 relays even though 2 were configured — its count wins.
|
||||||
|
want := []bool{true, false, true, false}
|
||||||
|
if len(st) != len(want) {
|
||||||
|
t.Fatalf("got %d relays, want %d", len(st), len(want))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if st[i] != want[i] {
|
||||||
|
t.Errorf("relay %d = %v, want %v", i+1, st[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.Count() != 4 {
|
||||||
|
t.Errorf("Count() = %d, want 4 (taken from the board)", d.Count())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDingtianSetUsesZeroBasedIndexAndSession(t *testing.T) {
|
||||||
|
var gotQuery, gotCookie string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotQuery = r.URL.RawQuery
|
||||||
|
gotCookie = r.Header.Get("Cookie")
|
||||||
|
_, _ = w.Write([]byte("&0&0&2&1&0&"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "12345678", "4660", 4)
|
||||||
|
if err := d.Set(context.Background(), 3, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotQuery, "relay=2") {
|
||||||
|
t.Errorf("relay 3 must go out as relay=2 (zero-based); query was %q", gotQuery)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotQuery, "on=1") || !strings.Contains(gotQuery, "type=0") ||
|
||||||
|
!strings.Contains(gotQuery, "pwd=4660") {
|
||||||
|
t.Errorf("unexpected query %q", gotQuery)
|
||||||
|
}
|
||||||
|
if gotCookie != "session=12345678" {
|
||||||
|
t.Errorf("session cookie = %q, want session=12345678", gotCookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDingtianSetRefusalIsAnError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
// Manual §3.4.4: a bad session still answers 200 OK.
|
||||||
|
_, _ = w.Write([]byte("&302&/&"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "", "", 2)
|
||||||
|
if err := d.Set(context.Background(), 1, true); err == nil {
|
||||||
|
t.Fatal("a refused command answered HTTP 200 and was reported as success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,8 +59,28 @@ type Server struct {
|
|||||||
ln net.Listener
|
ln net.Listener
|
||||||
conns map[net.Conn]struct{}
|
conns map[net.Conn]struct{}
|
||||||
closed bool
|
closed bool
|
||||||
|
|
||||||
|
// Optimistic frequency echo. A sharing client that follows our dial by
|
||||||
|
// polling get_freq (WSJT-X / JTDX in "Fake It" split) shifts the frequency on
|
||||||
|
// TX and restores it on RX. Freq() is the last value POLLED from the rig, and
|
||||||
|
// it lags a set_freq by up to a poll cycle (~100-200 ms). In that window the
|
||||||
|
// client — no longer transmitting — reads back the still-shifted frequency,
|
||||||
|
// mistakes it for a manual QSY and adopts it, so every over creeps the dial by
|
||||||
|
// the shift amount and it never comes back. Echoing the last commanded
|
||||||
|
// frequency until the rig confirms it (or a short deadline passes) closes the
|
||||||
|
// window: the client always reads exactly what it just set.
|
||||||
|
echoMu sync.Mutex
|
||||||
|
echoHz int64
|
||||||
|
echoAt time.Time
|
||||||
|
echoWant bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// freqEchoTTL caps how long a commanded frequency is echoed when the rig never
|
||||||
|
// reports it back (e.g. it rounded to a coarser step). Long enough to cover a
|
||||||
|
// poll cycle with margin, short enough that a genuine knob turn during the
|
||||||
|
// window surfaces quickly.
|
||||||
|
const freqEchoTTL = 2 * time.Second
|
||||||
|
|
||||||
func New(port int, rig Rig, logf func(string, ...any)) *Server {
|
func New(port int, rig Rig, logf func(string, ...any)) *Server {
|
||||||
if port <= 0 || port > 65535 {
|
if port <= 0 || port > 65535 {
|
||||||
port = 4532 // the rigctld default every client pre-fills
|
port = 4532 // the rigctld default every client pre-fills
|
||||||
@@ -193,7 +213,7 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
|||||||
return "", true
|
return "", true
|
||||||
|
|
||||||
case "f", "\\get_freq":
|
case "f", "\\get_freq":
|
||||||
return fmt.Sprintf("%d\n", s.rig.Freq()), false
|
return fmt.Sprintf("%d\n", s.reportedFreq()), false
|
||||||
case "F", "\\set_freq":
|
case "F", "\\set_freq":
|
||||||
if len(args) < 1 {
|
if len(args) < 1 {
|
||||||
return rprt(-1), false
|
return rprt(-1), false
|
||||||
@@ -210,6 +230,7 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
|||||||
s.log("rigctld: set_freq %d failed: %v", hz, err)
|
s.log("rigctld: set_freq %d failed: %v", hz, err)
|
||||||
return rprt(-9), false
|
return rprt(-9), false
|
||||||
}
|
}
|
||||||
|
s.noteSetFreq(hz)
|
||||||
return rprt(0), false
|
return rprt(0), false
|
||||||
|
|
||||||
case "m", "\\get_mode":
|
case "m", "\\get_mode":
|
||||||
@@ -263,7 +284,7 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
|||||||
case "i", "\\get_split_freq":
|
case "i", "\\get_split_freq":
|
||||||
_, tx := s.rig.Split()
|
_, tx := s.rig.Split()
|
||||||
if tx <= 0 {
|
if tx <= 0 {
|
||||||
tx = s.rig.Freq()
|
tx = s.reportedFreq()
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%d\n", tx), false
|
return fmt.Sprintf("%d\n", tx), false
|
||||||
case "I", "\\set_split_freq":
|
case "I", "\\set_split_freq":
|
||||||
@@ -279,6 +300,30 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
|||||||
|
|
||||||
func rprt(code int) string { return fmt.Sprintf("RPRT %d\n", code) }
|
func rprt(code int) string { return fmt.Sprintf("RPRT %d\n", code) }
|
||||||
|
|
||||||
|
// noteSetFreq records a frequency a client just commanded, so the next reads
|
||||||
|
// echo it back until the rig confirms the tune.
|
||||||
|
func (s *Server) noteSetFreq(hz int64) {
|
||||||
|
s.echoMu.Lock()
|
||||||
|
s.echoHz, s.echoAt, s.echoWant = hz, time.Now(), true
|
||||||
|
s.echoMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportedFreq is what get_freq answers: the last commanded frequency while the
|
||||||
|
// rig is still catching up to it, otherwise the live polled value. See echoWant.
|
||||||
|
func (s *Server) reportedFreq() int64 {
|
||||||
|
live := s.rig.Freq()
|
||||||
|
s.echoMu.Lock()
|
||||||
|
defer s.echoMu.Unlock()
|
||||||
|
if s.echoWant {
|
||||||
|
if live == s.echoHz || time.Since(s.echoAt) > freqEchoTTL {
|
||||||
|
s.echoWant = false // rig confirmed the tune, or we waited long enough
|
||||||
|
return live
|
||||||
|
}
|
||||||
|
return s.echoHz
|
||||||
|
}
|
||||||
|
return live
|
||||||
|
}
|
||||||
|
|
||||||
// stripVFOArg drops a leading VFO name from a command's arguments.
|
// stripVFOArg drops a leading VFO name from a command's arguments.
|
||||||
//
|
//
|
||||||
// Hamlib has two dialects. In the plain one a client sends "F 14074000"; in VFO
|
// Hamlib has two dialects. In the plain one a client sends "F 14074000"; in VFO
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type fakeRig struct {
|
|||||||
setFreqs []int64
|
setFreqs []int64
|
||||||
setModes []string
|
setModes []string
|
||||||
failSet bool
|
failSet bool
|
||||||
|
lagSet bool // record the command but do not move freq — simulate poll lag
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeRig) Freq() int64 { f.mu.Lock(); defer f.mu.Unlock(); return f.freq }
|
func (f *fakeRig) Freq() int64 { f.mu.Lock(); defer f.mu.Unlock(); return f.freq }
|
||||||
@@ -31,8 +32,10 @@ func (f *fakeRig) SetFreq(hz int64) error {
|
|||||||
if f.failSet {
|
if f.failSet {
|
||||||
return fmt.Errorf("rig refused")
|
return fmt.Errorf("rig refused")
|
||||||
}
|
}
|
||||||
f.freq = hz
|
|
||||||
f.setFreqs = append(f.setFreqs, hz)
|
f.setFreqs = append(f.setFreqs, hz)
|
||||||
|
if !f.lagSet {
|
||||||
|
f.freq = hz
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (f *fakeRig) SetMode(m string) error {
|
func (f *fakeRig) SetMode(m string) error {
|
||||||
@@ -138,6 +141,60 @@ func TestHandleReportsBackendFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The FT8 "Fake It" drift. JTDX/WSJT-X shift the dial down on TX and restore it
|
||||||
|
// on RX, and they follow the dial by polling get_freq. Our Freq() is the last
|
||||||
|
// POLLED value and lags a set_freq by a poll cycle, so right after the restore
|
||||||
|
// the client used to read the still-shifted frequency, take it for a manual QSY
|
||||||
|
// and adopt it — the dial crept down every over and never came back. get_freq
|
||||||
|
// now echoes the last commanded frequency until the rig confirms it.
|
||||||
|
func TestFakeItSplitDoesNotDriftTheDial(t *testing.T) {
|
||||||
|
// lagSet: SetFreq only records the command; we drive the poll catch-up by hand
|
||||||
|
// to land inside the exact window the drift lived in.
|
||||||
|
rig := &fakeRig{freq: 14074000, lagSet: true}
|
||||||
|
s := New(0, rig, nil)
|
||||||
|
|
||||||
|
if got, _ := s.handle("f"); got != "14074000\n" {
|
||||||
|
t.Fatalf("baseline get_freq = %q, want 14074000", got)
|
||||||
|
}
|
||||||
|
setFreq := func(hz int64) { rig.mu.Lock(); rig.freq = hz; rig.mu.Unlock() }
|
||||||
|
|
||||||
|
for cycle := 0; cycle < 5; cycle++ {
|
||||||
|
// TX: the client shifts the dial down for the over.
|
||||||
|
if got, _ := s.handle("F 14073500"); got != "RPRT 0\n" {
|
||||||
|
t.Fatalf("cycle %d TX set_freq = %q", cycle, got)
|
||||||
|
}
|
||||||
|
// Mid-TX, before the rig reports the move, the client reads back exactly
|
||||||
|
// what it commanded — not the stale 14074000.
|
||||||
|
if got, _ := s.handle("f"); got != "14073500\n" {
|
||||||
|
t.Fatalf("cycle %d TX get_freq = %q, want commanded 14073500", cycle, got)
|
||||||
|
}
|
||||||
|
setFreq(14073500) // poll catches up to the shifted dial
|
||||||
|
|
||||||
|
// RX: the client restores the dial.
|
||||||
|
if got, _ := s.handle("F 14074000"); got != "RPRT 0\n" {
|
||||||
|
t.Fatalf("cycle %d RX set_freq = %q", cycle, got)
|
||||||
|
}
|
||||||
|
// The rig has not yet reported the restore (still 14073500). Before the fix
|
||||||
|
// this returned 14073500 and JTDX adopted it — the cumulative drift. Now it
|
||||||
|
// echoes the restore, so the dial holds.
|
||||||
|
if got, _ := s.handle("f"); got != "14074000\n" {
|
||||||
|
t.Fatalf("cycle %d RX get_freq = %q, want restored 14074000 — dial drifted", cycle, got)
|
||||||
|
}
|
||||||
|
setFreq(14074000) // poll catches up to the restored dial
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once a poll confirms the commanded dial, the echo is released (in the field
|
||||||
|
// the client polls continuously, so this happens within a cycle).
|
||||||
|
if got, _ := s.handle("f"); got != "14074000\n" {
|
||||||
|
t.Fatalf("confirming get_freq = %q, want 14074000", got)
|
||||||
|
}
|
||||||
|
// A genuine knob turn now surfaces at once.
|
||||||
|
setFreq(14075000)
|
||||||
|
if got, _ := s.handle("f"); got != "14075000\n" {
|
||||||
|
t.Errorf("manual QSY get_freq = %q, want 14075000 — echo hid a real move", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// dump_state is parsed POSITIONALLY by Hamlib clients: WSJT-X reads the first
|
// dump_state is parsed POSITIONALLY by Hamlib clients: WSJT-X reads the first
|
||||||
// line as the protocol version and refuses to continue if the block is short or
|
// line as the protocol version and refuses to continue if the block is short or
|
||||||
// misshapen. Pinning its shape is what stops a well-meaning edit from silently
|
// misshapen. Pinning its shape is what stops a well-meaning edit from silently
|
||||||
|
|||||||
+129
-26
@@ -33,13 +33,11 @@ const (
|
|||||||
cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE (confirmed on hw)
|
cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE (confirmed on hw)
|
||||||
cmdStatus byte = 0x90 // request the status string
|
cmdStatus byte = 0x90 // request the status string
|
||||||
|
|
||||||
// Best-guess keystroke codes reconstructed from the APG command table after
|
// From the official APG rev 1.1 command table. Note POWER does NOT switch a
|
||||||
// correcting the PDF's note-wrap misalignment (anchored to the confirmed
|
// sleeping amp on (that is the RTS/DTR wake, see PowerOn) — on a running amp
|
||||||
// OPERATE=0x0D): OFF=0x0A, POWER=0x0B. The POWER key doubles as power-on (when
|
// it cycles the output level L→M→H, which is all we use it for.
|
||||||
// the amp is off) and the output-level cycle L→M→H (when it's on) — same as the
|
cmdOff byte = 0x0A // SWITCH OFF key — how PowerOff switches the amp off
|
||||||
// single physical POWER button. To be verified on hardware.
|
cmdPower byte = 0x0B // POWER key — cycles the output power level
|
||||||
cmdOff byte = 0x0A // OFF key — switch the amplifier off
|
|
||||||
cmdPower byte = 0x0B // POWER key — turn on / cycle output power level
|
|
||||||
|
|
||||||
syncHost = 0x55
|
syncHost = 0x55
|
||||||
syncAmp = 0xAA
|
syncAmp = 0xAA
|
||||||
@@ -47,11 +45,16 @@ const (
|
|||||||
dialTimeout = 5 * time.Second
|
dialTimeout = 5 * time.Second
|
||||||
ioTimeout = 3 * time.Second
|
ioTimeout = 3 * time.Second
|
||||||
pollEvery = 800 * time.Millisecond
|
pollEvery = 800 * time.Millisecond
|
||||||
|
|
||||||
|
// How long a modem line is held LOW before being raised again, to give the
|
||||||
|
// amp's edge-triggered remote-on input a transition it can see.
|
||||||
|
wakePulse = time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Status is the decoded amplifier state for the UI.
|
// Status is the decoded amplifier state for the UI.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
|
Transport string `json:"transport"` // "serial" | "tcp" — the UI enables power-ON on serial even when asleep
|
||||||
LastError string `json:"last_error,omitempty"`
|
LastError string `json:"last_error,omitempty"`
|
||||||
Model string `json:"model,omitempty"` // "20K" / "13K"
|
Model string `json:"model,omitempty"` // "20K" / "13K"
|
||||||
Operate bool `json:"operate"` // true = OPERATE, false = STANDBY
|
Operate bool `json:"operate"` // true = OPERATE, false = STANDBY
|
||||||
@@ -91,6 +94,8 @@ type Client struct {
|
|||||||
|
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
running bool
|
running bool
|
||||||
|
|
||||||
|
lastConnErr string // last connect failure logged (log only on change)
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg Config) *Client {
|
func New(cfg Config) *Client {
|
||||||
@@ -123,7 +128,13 @@ func (c *Client) Stop() {
|
|||||||
func (c *Client) GetStatus() Status {
|
func (c *Client) GetStatus() Status {
|
||||||
c.statusMu.RLock()
|
c.statusMu.RLock()
|
||||||
defer c.statusMu.RUnlock()
|
defer c.statusMu.RUnlock()
|
||||||
return c.status
|
s := c.status
|
||||||
|
if c.cfg.Transport == "tcp" {
|
||||||
|
s.Transport = "tcp"
|
||||||
|
} else {
|
||||||
|
s.Transport = "serial"
|
||||||
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) setErr(err error) {
|
func (c *Client) setErr(err error) {
|
||||||
@@ -146,27 +157,79 @@ func (c *Client) Operate(on bool) error {
|
|||||||
// ToggleOperate flips STANDBY/OPERATE unconditionally.
|
// ToggleOperate flips STANDBY/OPERATE unconditionally.
|
||||||
func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) }
|
func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) }
|
||||||
|
|
||||||
// PowerOn turns the amplifier on. Per the SPE manual this is NOT a data command
|
// PowerOn switches the amplifier on with a RISING EDGE on RTS, then on DTR.
|
||||||
// but a hardware DTR pulse on the serial line (the "Remote_ON" pin), which needs
|
//
|
||||||
// the special SPE cable — so it only applies to the serial transport. Over TCP
|
// Confirmed on a live 1.3K-FA. The remote-on input is EDGE-triggered, which is
|
||||||
// there is no DTR line and power-on isn't possible remotely.
|
// why holding the lines high does nothing: a plain port open already leaves both
|
||||||
|
// high (the serial library's default), so there is no transition left to give.
|
||||||
|
// Hence low → pause → high on each line, in that order. The keystroke route is a
|
||||||
|
// dead end — a switched-off amp does not answer its UART at all, and the POWER
|
||||||
|
// key (0x0B) merely cycles L/M/H on an amp that is already running.
|
||||||
|
//
|
||||||
|
// Serial only: over TCP there are no modem lines. The port is (re)opened first,
|
||||||
|
// since the poll loop has dropped the connection to a sleeping amp long before
|
||||||
|
// the user clicks ON.
|
||||||
func (c *Client) PowerOn() error {
|
func (c *Client) PowerOn() error {
|
||||||
|
if c.cfg.Transport == "tcp" {
|
||||||
|
return fmt.Errorf("power-on needs the serial RTS/DTR lines; not available over a network bridge")
|
||||||
|
}
|
||||||
|
// On an amp that is already awake there is nothing to wake.
|
||||||
|
if c.GetStatus().Connected {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The poll goroutine may still be inside a blocking read on the old handle;
|
||||||
|
// Windows keeps the port "busy" until that read times out (ioTimeout). Retry
|
||||||
|
// the open for a little longer than that.
|
||||||
|
var err error
|
||||||
|
deadline := time.Now().Add(ioTimeout + 3*time.Second)
|
||||||
|
for {
|
||||||
|
if err = c.ensureConn(); err == nil || time.Now().After(deadline) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("spe: power ON failed — cannot open %s: %v", c.cfg.ComPort, err)
|
||||||
|
return fmt.Errorf("power-on: %w", err)
|
||||||
|
}
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
|
||||||
sp, ok := c.conn.(serial.Port)
|
sp, ok := c.conn.(serial.Port)
|
||||||
|
c.mu.Unlock()
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("power-on needs the serial DTR line (special SPE cable); not available over network")
|
return fmt.Errorf("power-on needs a serial connection")
|
||||||
}
|
}
|
||||||
// A single positive pulse (~1.2s) on DTR, as the manual describes.
|
pulse := func(set func(bool) error) error {
|
||||||
if err := sp.SetDTR(true); err != nil {
|
if err := set(false); err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(wakePulse)
|
||||||
|
return set(true)
|
||||||
}
|
}
|
||||||
time.Sleep(1200 * time.Millisecond)
|
if err = pulse(sp.SetRTS); err == nil {
|
||||||
return sp.SetDTR(false)
|
err = pulse(sp.SetDTR)
|
||||||
|
}
|
||||||
|
// Booting, the amp re-enumerates its USB interface, which leaves this handle
|
||||||
|
// pointing at a device that no longer exists — the amp came up and OpsLog
|
||||||
|
// still read "offline". Drop it; the poll loop reopens a fresh one as soon as
|
||||||
|
// the port is back.
|
||||||
|
c.mu.Lock()
|
||||||
|
c.dropLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
applog.Printf("spe: power ON — RTS then DTR pulsed on %s (err=%v)", c.cfg.ComPort, err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// PowerOff presses the OFF key (switches the amp off).
|
// PowerOff presses the SWITCH OFF key (0x0A) — the amp is running, so it hears
|
||||||
func (c *Client) PowerOff() error { return c.sendCmd(cmdOff) }
|
// its UART. Dropping DTR then RTS switches it off too (it is the mirror of the
|
||||||
|
// wake, and does work when done by hand), but back-to-back from one click it
|
||||||
|
// did not, while this keystroke has never once failed. The amp then stays off:
|
||||||
|
// the poll loop keeps reopening the port with both lines high and that has
|
||||||
|
// never woken it — only the deliberate low→high sequence in PowerOn does.
|
||||||
|
func (c *Client) PowerOff() error {
|
||||||
|
err := c.sendCmd(cmdOff)
|
||||||
|
applog.Printf("spe: power OFF — SWITCH OFF key sent (err=%v)", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// SetPowerLevel cycles the output power level (L/M/H) to the requested one by
|
// SetPowerLevel cycles the output power level (L/M/H) to the requested one by
|
||||||
// tapping the POWER key (0x0B) and WAITING for the amp to actually report the new
|
// tapping the POWER key (0x0B) and WAITING for the amp to actually report the new
|
||||||
@@ -206,9 +269,16 @@ func (c *Client) pollLoop() {
|
|||||||
return
|
return
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
if err := c.ensureConn(); err != nil {
|
if err := c.ensureConn(); err != nil {
|
||||||
|
// Logged once per distinct message: "the amp is on but OpsLog
|
||||||
|
// says offline" was impossible to diagnose without the reason.
|
||||||
|
if msg := err.Error(); msg != c.lastConnErr {
|
||||||
|
c.lastConnErr = msg
|
||||||
|
applog.Printf("spe: connect %s failed: %s", c.cfg.ComPort, msg)
|
||||||
|
}
|
||||||
c.setErr(fmt.Errorf("connect: %w", err))
|
c.setErr(fmt.Errorf("connect: %w", err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
c.lastConnErr = ""
|
||||||
// The amp streams status frames faster than one per poll, so a backlog
|
// The amp streams status frames faster than one per poll, so a backlog
|
||||||
// builds up and we'd read stale frames (the display lagged ~15s behind
|
// builds up and we'd read stale frames (the display lagged ~15s behind
|
||||||
// the real amp). Flush anything pending, THEN request + read exactly one
|
// the real amp). Flush anything pending, THEN request + read exactly one
|
||||||
@@ -239,16 +309,48 @@ func (c *Client) ensureConn() error {
|
|||||||
nc, err = net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
|
nc, err = net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
|
||||||
rwc = nc
|
rwc = nc
|
||||||
} else {
|
} else {
|
||||||
rwc, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
|
var sp serial.Port
|
||||||
|
// Plain open: both modem lines come up high (the library's default), which
|
||||||
|
// is what the interface needs to talk. That is not enough to switch the amp
|
||||||
|
// on — the remote-on input wants the deliberate low→high sequence PowerOn
|
||||||
|
// sends — so reconnecting never wakes an amplifier the operator switched
|
||||||
|
// off, and forcing either line low here would switch a running one off.
|
||||||
|
sp, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
|
||||||
|
if err == nil {
|
||||||
|
// Without this, a read on a sleeping amp blocks forever inside the
|
||||||
|
// Windows serial driver — the poll goroutine survived the window and
|
||||||
|
// the process took ~a minute to die.
|
||||||
|
_ = sp.SetReadTimeout(ioTimeout)
|
||||||
|
rwc = sp
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c.conn = rwc
|
c.conn = rwc
|
||||||
c.r = bufio.NewReader(rwc)
|
if sp, ok := rwc.(serial.Port); ok {
|
||||||
|
// The driver reports a read timeout as "0 bytes, no error", which bufio
|
||||||
|
// spins on; surface it as a real error so the poll loop drops and retries.
|
||||||
|
c.r = bufio.NewReader(serialTimeoutReader{sp})
|
||||||
|
} else {
|
||||||
|
c.r = bufio.NewReader(rwc)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serialTimeoutReader converts the serial driver's timeout convention (n=0,
|
||||||
|
// err=nil once SetReadTimeout expires) into an explicit error, so a bufio
|
||||||
|
// reader fails fast instead of retrying an amp that is asleep.
|
||||||
|
type serialTimeoutReader struct{ p serial.Port }
|
||||||
|
|
||||||
|
func (s serialTimeoutReader) Read(b []byte) (int, error) {
|
||||||
|
n, err := s.p.Read(b)
|
||||||
|
if n == 0 && err == nil {
|
||||||
|
return 0, fmt.Errorf("spe: read timeout")
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) dropLocked() {
|
func (c *Client) dropLocked() {
|
||||||
if c.conn != nil {
|
if c.conn != nil {
|
||||||
c.conn.Close()
|
c.conn.Close()
|
||||||
@@ -361,9 +463,10 @@ func (c *Client) decodeCSV(payload string) {
|
|||||||
|
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
defer c.statusMu.Unlock()
|
defer c.statusMu.Unlock()
|
||||||
// Log the raw status frame ONCE per change, so field alignment can be verified
|
// One raw frame per session is enough to verify field alignment against real
|
||||||
// against real hardware (the doc's 19-field layout may differ per firmware).
|
// hardware — the frame carries live meter values, so logging each change
|
||||||
if payload != c.lastRaw {
|
// meant logging nearly every frame.
|
||||||
|
if c.lastRaw == "" {
|
||||||
c.lastRaw = payload
|
c.lastRaw = payload
|
||||||
applog.Printf("spe: status raw=%q fields=%d", payload, len(f))
|
applog.Printf("spe: status raw=%q fields=%d", payload, len(f))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ type Status struct {
|
|||||||
|
|
||||||
FwdDbm float64 `json:"fwd_dbm"` // forward power [dBm], as reported
|
FwdDbm float64 `json:"fwd_dbm"` // forward power [dBm], as reported
|
||||||
FwdW float64 `json:"fwd_w"` // forward power [W], derived from dBm
|
FwdW float64 `json:"fwd_w"` // forward power [W], derived from dBm
|
||||||
|
PeakW float64 `json:"peak_w"` // the device's own peak forward power [W] — what the meters show
|
||||||
SwrDb float64 `json:"swr_db"` // return loss [dB] as reported (negative = good match)
|
SwrDb float64 `json:"swr_db"` // return loss [dB] as reported (negative = good match)
|
||||||
Vswr float64 `json:"vswr"` // VSWR ratio, derived from swr_db (1.0 = perfect)
|
Vswr float64 `json:"vswr"` // VSWR ratio, derived from swr_db (1.0 = perfect)
|
||||||
|
|
||||||
@@ -405,7 +406,9 @@ func (c *Client) parse(resp string) {
|
|||||||
if !strings.HasPrefix(data, "status") {
|
if !strings.HasPrefix(data, "status") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if data != c.lastRaw {
|
// One raw frame per session is enough to verify field alignment — the
|
||||||
|
// frames carry live meter values, so "log on change" logged every frame.
|
||||||
|
if c.lastRaw == "" {
|
||||||
c.lastRaw = data
|
c.lastRaw = data
|
||||||
applog.Printf("tunergenius: status raw=%q", data)
|
applog.Printf("tunergenius: status raw=%q", data)
|
||||||
}
|
}
|
||||||
@@ -439,6 +442,13 @@ func (c *Client) applyStatus(data string) {
|
|||||||
c.status.FwdDbm = v
|
c.status.FwdDbm = v
|
||||||
c.status.FwdW = dbmToWatts(v)
|
c.status.FwdW = dbmToWatts(v)
|
||||||
}
|
}
|
||||||
|
// The device's OWN peak detector. A poll samples one instant of the envelope,
|
||||||
|
// so on SSB the plain "fwd" figure lands between syllables as often as on a
|
||||||
|
// peak — the widget read a few hundred watts on a kilowatt transmission. This
|
||||||
|
// is the number to display.
|
||||||
|
if v, ok := parseFloat(kv["peak"]); ok {
|
||||||
|
c.status.PeakW = dbmToWatts(v)
|
||||||
|
}
|
||||||
if v, ok := parseFloat(kv["swr"]); ok {
|
if v, ok := parseFloat(kv["swr"]); ok {
|
||||||
c.status.SwrDb = v
|
c.status.SwrDb = v
|
||||||
c.status.Vswr = returnLossToVswr(v)
|
c.status.Vswr = returnLossToVswr(v)
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ const (
|
|||||||
ubReadTimeout = 4 * time.Second // was 1s — too tight for a remote link
|
ubReadTimeout = 4 * time.Second // was 1s — too tight for a remote link
|
||||||
ubKeepAlive = 15 * time.Second // OS-level TCP keepalive
|
ubKeepAlive = 15 * time.Second // OS-level TCP keepalive
|
||||||
ubMaxPollTimeout = 3 // consecutive read timeouts tolerated before reconnecting
|
ubMaxPollTimeout = 3 // consecutive read timeouts tolerated before reconnecting
|
||||||
|
// How long a just-commanded direction is trusted AFTER the motors have stopped
|
||||||
|
// but before the antenna's status confirms it. The timer is held off entirely
|
||||||
|
// while the motors are still moving, so this is only the grace period for the
|
||||||
|
// confirmation poll to arrive once the elements have settled — generous, because
|
||||||
|
// over a remote link that poll lags by several seconds.
|
||||||
|
ubPendingDirGrace = 8 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Protocol constants
|
// Protocol constants
|
||||||
@@ -221,12 +227,30 @@ func (c *Client) pollLoop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
// Keep a just-commanded direction until the antenna reports it.
|
// Keep a just-commanded direction until the antenna actually reports it.
|
||||||
|
// Over a remote link the confirmation arrives several seconds after the
|
||||||
|
// command — the motors flip the elements first — so the old fixed 4 s
|
||||||
|
// timeout expired WHILE the change was still in flight, and the stale poll
|
||||||
|
// reverted the UI to the old pattern even though the antenna was on its way
|
||||||
|
// to the new one. Now: while the motors are still moving the change is in
|
||||||
|
// progress, so hold the commanded pattern and keep resetting the timer;
|
||||||
|
// only once the motors have stopped does the short grace window run, giving
|
||||||
|
// the confirmation poll time to land. The poll only wins if the motors are
|
||||||
|
// idle AND the antenna still reports a different pattern past that window —
|
||||||
|
// i.e. the command genuinely did not take.
|
||||||
if c.pendingDirSet {
|
if c.pendingDirSet {
|
||||||
if time.Since(c.pendingDirAt) > 4*time.Second || status.Direction == c.pendingDir {
|
if status.MotorsMoving != 0 {
|
||||||
c.pendingDirSet = false
|
c.pendingDirAt = time.Now() // still repositioning — don't start the grace timer
|
||||||
} else {
|
}
|
||||||
status.Direction = c.pendingDir
|
switch {
|
||||||
|
case status.Direction == c.pendingDir:
|
||||||
|
c.pendingDirSet = false // confirmed by the antenna
|
||||||
|
case time.Since(c.pendingDirAt) > ubPendingDirGrace:
|
||||||
|
c.pendingDirSet = false // motors idle, still unconfirmed → accept the poll
|
||||||
|
log.Printf("Ultrabeam: antenna never confirmed direction %d (reports %d) — dropping the hold",
|
||||||
|
c.pendingDir, status.Direction)
|
||||||
|
default:
|
||||||
|
status.Direction = c.pendingDir // still changing, or within the grace window
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.lastStatus = status
|
c.lastStatus = status
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.22.7"
|
appVersion = "0.23.5"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
@@ -1,13 +1,47 @@
|
|||||||
# Amplifiers and Switches
|
# Amplifiers and Switches
|
||||||
|
|
||||||
## PowerGenius XL (4O3A) amplifier
|
## Amplifiers
|
||||||
|
|
||||||
Direct TCP connection (Settings → PowerGenius):
|
Settings → **Amplifier** is a **list**: configure several amps (e.g. two SPEs
|
||||||
|
run in parallel), each with its own name and connection. Brands:
|
||||||
|
|
||||||
- **Operate / Standby** and **fault** display.
|
- **SPE Expert** (1.3K / 1.5K / 2K) — USB serial or an RS232-to-Ethernet
|
||||||
- **Fan-mode** selector (Standard / Contest / Broadcast).
|
bridge. OPERATE/STANDBY, ON/OFF, Low/Mid/High power level, live status.
|
||||||
- When a Flex is connected, the amp controls + its **FWD / ID / TEMP** meters
|
Switching the amplifier **on and off** uses the remote-on control lines, so
|
||||||
appear in the FlexRadio panel's Amplifier card. See [[FlexRadio]].
|
it needs the **USB (or RS-232) connection**, not a network bridge — the ON
|
||||||
|
button works even while the amplifier is off and reporting nothing.
|
||||||
|
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) — serial or bridge.
|
||||||
|
OPERATE/STANDBY/OFF, live power / SWR / PA temperature / band / fan.
|
||||||
|
Power-ON works over serial when the cable wires the DTR/RTS pins.
|
||||||
|
- **PowerGenius XL** (4O3A) — direct TCP. Operate/Standby, fault display, and a
|
||||||
|
**fan-mode** selector (Standard / Contest / Broadcast).
|
||||||
|
|
||||||
|
Where the controls appear:
|
||||||
|
|
||||||
|
- An **amplifier card** in the FlexRadio panel and in **Station Control**:
|
||||||
|
OPERATE/STANDBY, ON/OFF, power level, output-power bar and live FWD /
|
||||||
|
drain-current / temperature meters. Several amps → one card each.
|
||||||
|
- A **status chip per amp** in the bottom bar: green ON AIR = OPERATE, orange =
|
||||||
|
STANDBY, red = offline. Clicking it toggles OPERATE/STANDBY.
|
||||||
|
- A **docked widget** (flame icon, next to the keyers): Operate, power ON/OFF,
|
||||||
|
the SPE power level and live watts / SWR / temperature, in the narrow column
|
||||||
|
format of the Tuner Genius widget. With several amplifiers configured, a
|
||||||
|
dropdown on the icon picks which one it shows — or **All**.
|
||||||
|
|
||||||
|
### Band-follow (amp on a second COM port)
|
||||||
|
|
||||||
|
An ACOM or SPE that normally polls the radio for its frequency can follow
|
||||||
|
OpsLog instead: OpsLog answers the amp's polls **as a Kenwood rig** on a second
|
||||||
|
COM port (Settings → Amplifier → frequency port). Useful when the real radio's
|
||||||
|
CAT port is already taken by OpsLog itself.
|
||||||
|
|
||||||
|
## Tuner Genius XL (4O3A)
|
||||||
|
|
||||||
|
Settings → Tuner Genius (IP only; port fixed at 9010). Live **SWR and forward
|
||||||
|
power** with peak-hold, **Tune / Bypass / Operate-Standby**, and the two
|
||||||
|
channels A/B (source, frequency, antenna) shown and click-selectable. Available
|
||||||
|
as a docked widget, a card in the FlexRadio panel and a card in Station
|
||||||
|
Control. Direct TCP — uses one of the box's connection slots.
|
||||||
|
|
||||||
## Antenna Genius (4O3A) antenna switch
|
## Antenna Genius (4O3A) antenna switch
|
||||||
|
|
||||||
@@ -16,8 +50,29 @@ Over TCP / GSCP — a docked **A/B antenna-switch** widget:
|
|||||||
- One row per configured antenna, with a **Port A** and **Port B** button.
|
- One row per configured antenna, with a **Port A** and **Port B** button.
|
||||||
- Colours: green = selected on port A, blue = selected on port B, red (pulsing) =
|
- Colours: green = selected on port A, blue = selected on port B, red (pulsing) =
|
||||||
that port is transmitting. Clicking a selected port deselects it (→ None).
|
that port is transmitting. Clicking a selected port deselects it (→ None).
|
||||||
- **Band filter** (funnel icon in the header): shows only the antennas configured
|
- **Band filter** (funnel icon): shows only the antennas configured for the
|
||||||
for the current band, like the native app. Toggle it off to see all antennas.
|
current band. Antennas with an unknown band mask are always shown.
|
||||||
Antennas with an unknown band mask are always shown.
|
|
||||||
|
|
||||||
Configure the host / password in Settings → Antenna Genius.
|
Configure the host / password in Settings → Antenna Genius.
|
||||||
|
|
||||||
|
## Relay boards (Station Control)
|
||||||
|
|
||||||
|
Relay boards for antenna/accessory switching, with named buttons in Station
|
||||||
|
Control:
|
||||||
|
|
||||||
|
- **WebSwitch 1216H** (5 relays, LAN) and **KMTronic** 8-relay WEB (LAN, with
|
||||||
|
optional HTTP authentication).
|
||||||
|
- **Dingtian IOT relay** (2 to 32 relays, LAN or WiFi), driven over its HTTP
|
||||||
|
CGI. Give its IP address and pick the relay count; the **relay password** and
|
||||||
|
**session ID** fields are only needed if you enabled them on the board's own
|
||||||
|
web page — a factory board needs neither.
|
||||||
|
- **Denkovi** USB (4/8 relays, FT245) and generic **CH340 / LCUS** USB-serial
|
||||||
|
boards.
|
||||||
|
|
||||||
|
A Test-connection button and detection feedback live in the setup panel.
|
||||||
|
|
||||||
|
## Station Control
|
||||||
|
|
||||||
|
The Station Control tab is a dashboard of fixed-width panels that wrap to fill
|
||||||
|
the window — amplifier(s), tuner, relays, rotator, Ultrabeam… Drag a panel by
|
||||||
|
the grip on its left edge to reorder; the order is remembered.
|
||||||
|
|||||||
+53
-16
@@ -2,35 +2,72 @@
|
|||||||
|
|
||||||
## CW keyer
|
## CW keyer
|
||||||
|
|
||||||
A CW keyer with **macros** and F-key macros. The keyer **engine** is selectable
|
A CW keyer with **macros** on F-keys (F1–F9; empty slots are hidden). The keyer
|
||||||
(Settings → CW Keyer):
|
**engine** is selectable (Settings → CW Keyer):
|
||||||
|
|
||||||
- **WinKeyer** — K1EL WK1/2/3 over a COM port.
|
- **WinKeyer** — K1EL WK1/2/3 over a COM port. The log names the generation
|
||||||
|
detected, and a byte-level trace can be enabled for bug reports.
|
||||||
|
- **Serial port (DTR=CW / RTS=PTT)** — OpsLog keys CW by toggling a serial
|
||||||
|
control line itself, the hardware method N1MM/WSJT-X use with a Yaesu SCU-17
|
||||||
|
or a generic keying interface. No WinKeyer needed. Choose the COM port, which
|
||||||
|
line carries CW, and *Invert keying* for interfaces that key backwards. This
|
||||||
|
is also the engine for an **FTDX10** (whose CAT refuses `KY`), on the rig's
|
||||||
|
second COM port.
|
||||||
- **Icom** — the radio's own keyer over CI-V, no extra hardware. Works over the
|
- **Icom** — the radio's own keyer over CI-V, no extra hardware. Works over the
|
||||||
remote link too. (Requires the Icom CAT backend; set break-in to SEMI/FULL or
|
remote link too. (Set break-in to SEMI/FULL or the rig keys the sidetone but
|
||||||
the rig keys the sidetone but stays in RX.)
|
stays in RX.)
|
||||||
|
- **FlexRadio (CWX)** — keys CW straight over the SmartSDR connection, with
|
||||||
|
type-ahead and mid-send backspace.
|
||||||
|
- **Yaesu (rig keyer)** — the radio's internal keyer over CAT (`KY`), for the
|
||||||
|
FTDX101 / FT-991A / FT-710 family. See [[Yaesu]].
|
||||||
- **TCI** — for TCI backends.
|
- **TCI** — for TCI backends.
|
||||||
|
|
||||||
|
**ESC** stops CW on whichever engine is active; typing a callsign aborts a
|
||||||
|
running macro. `<LOGQSO>` inside a macro logs the contact at that exact point.
|
||||||
|
**Auto-call** repeats a CQ with the gap you set measured *after* the message.
|
||||||
|
|
||||||
|
### ESM — Enter Sends Message
|
||||||
|
|
||||||
|
N1MM-style, for CW (Settings → CW Keyer). With the keyer on, **Enter** fires a
|
||||||
|
macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign
|
||||||
|
entered → F2 (report), focus jumps to RST; Enter in RST → F3 (TU), which logs
|
||||||
|
if the macro contains `<LOGQSO>`.
|
||||||
|
|
||||||
## Digital Voice Keyer (DVK)
|
## Digital Voice Keyer (DVK)
|
||||||
|
|
||||||
Record **F1–F6** voice messages and transmit them. Set the audio devices, PTT
|
Record **F1–F6** voice messages and transmit them. Set the audio devices, PTT
|
||||||
method and gains in Settings → Audio.
|
method and gains in Settings → Audio.
|
||||||
|
|
||||||
- **PTT** method: CAT, RTS, DTR, or none (VOX).
|
- **PTT** method: CAT (whichever backend is active — every one supports PTT),
|
||||||
- **Test PTT** keys the rig briefly. On CAT/OmniRig the key is held ~1.5 s so the
|
RTS, DTR, or none (VOX).
|
||||||
transmit command is actually sent (OmniRig is asynchronous and coalesces rapid
|
- **Auto CQ** repeats a CQ-labelled message on a timer until you stop it.
|
||||||
writes).
|
- The DVK refuses to transmit on non-phone modes — no speech on FT8.
|
||||||
|
- A **message level** control (Settings → Audio) drives the rig at the right
|
||||||
|
level even from a quietly recorded microphone. If the rig transmits almost
|
||||||
|
nothing, check its rear-input setting (on an FTDX10: SSB MOD SOURCE = REAR).
|
||||||
|
|
||||||
## QSO audio recording
|
## QSO audio recording
|
||||||
|
|
||||||
Continuous rolling capture. On **Log QSO** the contact is saved to a per-QSO WAV
|
Continuous rolling capture. On **Log QSO** the contact is saved to a per-QSO
|
||||||
(`CALL_YYYYMMDD_HHMMSS.wav`), mixing RX + mic. Configure the recordings folder,
|
WAV/MP3 (`CALL_YYYYMMDD_HHMMSS`), with pre-roll. With automatic recording off, a
|
||||||
format (WAV / MP3), pre-roll and gains in Settings → Audio.
|
**red dot** beside the callsign records the contact by hand.
|
||||||
|
|
||||||
|
A recording can be **replayed on the air** to the station being worked, like a
|
||||||
|
voice-keyer message — with its **own level**, separate from the voice keyer (a
|
||||||
|
receiver line-out needs far less gain than a microphone). The play button
|
||||||
|
becomes a **stop** button while it transmits. Hidden on CW.
|
||||||
|
|
||||||
|
## CW decoder (RX audio → text)
|
||||||
|
|
||||||
|
**Tools → CW decoder** (or the ear button) decodes received CW while the mode
|
||||||
|
is CW: adaptive envelope with noise squelch, per-character dit/dah
|
||||||
|
classification, follows 12–40 WPM and sloppy fists, rides QSB. Shows WPM /
|
||||||
|
pitch / level, locks onto the FlexRadio CW pitch automatically, and clicking a
|
||||||
|
decoded word fills the callsign. Clean-to-moderate signals decode solidly;
|
||||||
|
weak-signal pileups remain CW Skimmer territory.
|
||||||
|
|
||||||
## RX audio monitor (USB rigs)
|
## RX audio monitor (USB rigs)
|
||||||
|
|
||||||
Settings → Audio → **Listen to radio** pipes the rig's received audio (its "USB
|
Settings → Audio → **Listen to radio** pipes the rig's received audio (its "USB
|
||||||
Audio CODEC" input) through your speakers, so you hear the radio inside OpsLog.
|
Audio CODEC" input) through your speakers — the level applies live as you drag
|
||||||
**Talk to radio** keys PTT and pipes your mic to the rig's audio input. (Network
|
it. **Talk to radio** keys PTT and pipes your mic to the rig's audio input.
|
||||||
audio for remote Icom is a work in progress — see
|
|
||||||
[[Remote Icom over the Internet]].)
|
|
||||||
|
|||||||
+18
-4
@@ -6,8 +6,15 @@ shared **globally** across profiles.
|
|||||||
## Built-in awards
|
## Built-in awards
|
||||||
|
|
||||||
DXCC, WAS / WAZ / WAC, WPX, IOTA / POTA / SOTA / WWFF, **DDFM** (French
|
DXCC, WAS / WAZ / WAC, WPX, IOTA / POTA / SOTA / WWFF, **DDFM** (French
|
||||||
departments), and more — tracked **worked / confirmed / validated** by band and
|
departments), **DLD** (DARC DOK), **H26** (Helvetia 26 cantons), the US
|
||||||
mode.
|
counties (USA-CA), and more — tracked **worked / confirmed / validated** by
|
||||||
|
band and mode.
|
||||||
|
|
||||||
|
The awards panel has a **mode filter** (All / CW / Phone / Digital) that
|
||||||
|
applies to the whole award — bands, counts, confirmations and the contacts
|
||||||
|
behind a cell — and stacks with the worked/confirmed filter, so "worked on CW
|
||||||
|
but not confirmed on CW" is one click. Reference lists sort by reference or by
|
||||||
|
description.
|
||||||
|
|
||||||
## How matching works
|
## How matching works
|
||||||
|
|
||||||
@@ -121,13 +128,20 @@ drop off the list. This is how you close the gaps a matcher can't fill on its ow
|
|||||||
|
|
||||||
## Live detection & manual refs
|
## Live detection & manual refs
|
||||||
|
|
||||||
- References are detected **live** as you enter a callsign.
|
- References are detected **live** as you enter a callsign — from the looked-up
|
||||||
|
address *and* from the QTH, name, country, comment, note or grid you typed.
|
||||||
|
- An award can be marked **one reference per QSO** (used by DLD). When several
|
||||||
|
references then match the same contact, none is assigned silently — the
|
||||||
|
candidates are offered at log time and you pick. Off by default, so an n-fer
|
||||||
|
POTA activation still counts every park.
|
||||||
- You can **manually assign** a reference to a QSO (the *Award Refs* tab of the
|
- You can **manually assign** a reference to a QSO (the *Award Refs* tab of the
|
||||||
QSO editor). It's stored in an ADIF extra (`APP_OPSLOG_AWARDREFS`) so it
|
QSO editor). It's stored in an ADIF extra (`APP_OPSLOG_AWARDREFS`) so it
|
||||||
survives export/import — see [[Import and Export ADIF]] — and is honoured
|
survives export/import — see [[Import and Export ADIF]] — and is honoured
|
||||||
everywhere (award panel, grid columns, totals). For a list-backed award the
|
everywhere (award panel, grid columns, totals). For a list-backed award the
|
||||||
assigned reference must still be a **valid, listed** reference, and the QSO
|
assigned reference must still be a **valid, listed** reference, and the QSO
|
||||||
must be **in scope** (rule 5) for it to count.
|
must be **in scope** (rule 5) for it to count. A hand-assigned reference
|
||||||
|
**replaces** what the matcher found, so a correction survives the next
|
||||||
|
recompute.
|
||||||
|
|
||||||
## Reference lists & display
|
## Reference lists & display
|
||||||
|
|
||||||
|
|||||||
+52
-14
@@ -1,28 +1,46 @@
|
|||||||
# CAT Control
|
# CAT Control
|
||||||
|
|
||||||
OpsLog has **four native CAT backends** (Settings → CAT). Each auto-reconnects
|
OpsLog has **eight CAT backends** (Settings → CAT). Each auto-reconnects and
|
||||||
and connects *non-blocking*, so a powered-off radio never freezes the app.
|
connects *non-blocking*, so a powered-off radio never freezes the app. Backends
|
||||||
|
are named by **how the radio is reached**:
|
||||||
|
|
||||||
| Backend | Use it for |
|
| Backend | Use it for |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| **OmniRig** | Any OmniRig-supported rig (Rig 1 / Rig 2, hot-swap). |
|
| **OmniRig** | Any OmniRig-supported rig (Rig 1 / Rig 2, hot-swap). |
|
||||||
| **FlexRadio (SmartSDR)** | FlexRadio 6000/8000 over the TCP API. → [[FlexRadio]] |
|
| **FlexRadio (API)** | FlexRadio 6000/8000 over the TCP API. → [[FlexRadio]] |
|
||||||
| **Icom CI-V** | Any Icom over **USB** *or* over the internet via its **LAN server**. → [[Icom]] · [[Remote Icom over the Internet]] |
|
| **Yaesu (USB)** | FTDX10 / FTDX101 family, direct serial — no OmniRig. → [[Yaesu]] |
|
||||||
|
| **Kenwood (USB, network)** | TS-590 / TS-890 / TS-990 / TS-2000 (Elecraft K3/K4 speak the same dialect), serial or a `host:port` serial bridge. → [[Kenwood and Xiegu]] |
|
||||||
|
| **Xiegu (USB)** | G90 / X6100 / X6200 / X5105 — CI-V with their reduced command set. → [[Kenwood and Xiegu]] |
|
||||||
|
| **Icom (CI-V USB)** | Any Icom over USB. → [[Icom]] |
|
||||||
|
| **Icom (CI-V network)** | An Icom's built-in LAN/internet server, replacing RS-BA1. → [[Remote Icom over the Internet]] |
|
||||||
| **TCI** | SunSDR / ExpertSDR2 and any TCI (WebSocket) server. |
|
| **TCI** | SunSDR / ExpertSDR2 and any TCI (WebSocket) server. |
|
||||||
|
|
||||||
Once connected, frequency / band / mode follow the radio, clicking a cluster spot
|
Once connected, frequency / band / mode follow the radio, clicking a cluster
|
||||||
tunes the rig, and (for Flex) cluster spots can be pushed to the panadapter.
|
spot tunes the rig, and (for Flex) cluster spots can be pushed to the panadapter.
|
||||||
|
|
||||||
|
## Sharing the rig with WSJT-X / JTDX / MSHV (rigctld)
|
||||||
|
|
||||||
|
A native backend holds the radio's serial port **exclusively** — so OpsLog can
|
||||||
|
serve its rig connection to other programs instead: **Settings → CAT → Share
|
||||||
|
CAT**. In WSJT-X / JTDX / MSHV / Log4OM pick rig model **Hamlib NET rigctl** at
|
||||||
|
`127.0.0.1:4532`. Works with every backend and both Hamlib command dialects.
|
||||||
|
|
||||||
|
> Never point two programs at the same COM port. If another logger or WSJT-X
|
||||||
|
> opens the rig's port directly while OpsLog holds it, you get "port busy"
|
||||||
|
> errors, garbled CI-V frames and erratic rig behaviour. One master — OpsLog —
|
||||||
|
> and everyone else through the shared rigctl port.
|
||||||
|
|
||||||
## OmniRig
|
## OmniRig
|
||||||
|
|
||||||
Pick **Rig 1** or **Rig 2** (the two OmniRig slots). Configure the actual COM
|
Pick **Rig 1** or **Rig 2** (the two OmniRig slots). Configure the actual COM
|
||||||
port / baud in OmniRig's own settings. Works with anything OmniRig supports; PTT
|
port / baud in OmniRig's own settings.
|
||||||
keying via CAT is supported where the rig's OmniRig profile exposes it.
|
|
||||||
|
|
||||||
## FlexRadio
|
- **VFO to read** — some OmniRig rig files declare the wrong active VFO (an
|
||||||
|
IC-7610 file declared VFO B). If the frequency looks frozen or follows the
|
||||||
Enter the radio's IP (UDP discovery assists). You get a full SmartSDR-style
|
wrong VFO, force **A** or **B** here.
|
||||||
control tab — see **[[FlexRadio]]**.
|
- OpsLog and OmniRig must run at the **same privilege level** — one elevated
|
||||||
|
("as administrator") and not the other cannot see each other; OpsLog detects
|
||||||
|
and names this case.
|
||||||
|
|
||||||
## Icom CI-V (USB)
|
## Icom CI-V (USB)
|
||||||
|
|
||||||
@@ -31,7 +49,7 @@ control tab — see **[[FlexRadio]]**.
|
|||||||
- **COM port** + **baud** must match the radio (set *CI-V USB Echo Back* **OFF**
|
- **COM port** + **baud** must match the radio (set *CI-V USB Echo Back* **OFF**
|
||||||
on the rig).
|
on the rig).
|
||||||
- The model choice also tailors the console (e.g. attenuator steps: 20 dB on an
|
- The model choice also tailors the console (e.g. attenuator steps: 20 dB on an
|
||||||
IC-7300, 6/12/18 dB on an IC-7610).
|
IC-7300, 6/12/18 dB on an IC-7610/7800).
|
||||||
|
|
||||||
Full console details: **[[Icom]]**.
|
Full console details: **[[Icom]]**.
|
||||||
|
|
||||||
@@ -40,10 +58,30 @@ Full console details: **[[Icom]]**.
|
|||||||
Enter the TCI host + port (default 40001). Gives freq / mode / PTT / split and,
|
Enter the TCI host + port (default 40001). Gives freq / mode / PTT / split and,
|
||||||
optionally, panorama spots.
|
optionally, panorama spots.
|
||||||
|
|
||||||
|
## Serial lines (DTR / RTS)
|
||||||
|
|
||||||
|
Windows raises the DTR and RTS lines when a serial port opens, and some
|
||||||
|
interfaces read one of them as **PTT** — the radio goes into transmit the moment
|
||||||
|
OpsLog connects. Others need the lines **high** to work at all. So:
|
||||||
|
|
||||||
|
- **Xiegu** always lowers both on connect (a G90 behind a DE-19 keyed on open),
|
||||||
|
and has a setting for which line keys the rig.
|
||||||
|
- **Yaesu and Kenwood** have an option (off by default) to lower both lines on
|
||||||
|
connect — turn it on only if your interface transmits as soon as OpsLog opens
|
||||||
|
the port.
|
||||||
|
- **Icom CI-V** has always dropped them.
|
||||||
|
|
||||||
|
## Protocol trace
|
||||||
|
|
||||||
|
Settings → CAT can log the **raw protocol** (every CI-V frame, or every Kenwood
|
||||||
|
command, as hex/text) to the diagnostic log — invaluable when reporting a radio
|
||||||
|
that answers oddly. The checkbox shows whether the trace is really running.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- The **digital sub-mode** (FT4 vs FT8) is inferred from the frequency.
|
- The **digital sub-mode** (FT4 vs FT8) is inferred from the frequency.
|
||||||
|
- A radio reporting LSB or USB selects **SSB** in the entry form.
|
||||||
- **Per-band Flex RX/TX antennas** can be configured and are applied
|
- **Per-band Flex RX/TX antennas** can be configured and are applied
|
||||||
automatically on band change (Settings → FlexRadio).
|
automatically on band change (Settings → CAT → FlexRadio).
|
||||||
- Split, RIT/XIT and other rig-specific features are exposed on each backend's
|
- Split, RIT/XIT and other rig-specific features are exposed on each backend's
|
||||||
control tab where supported.
|
control tab where supported.
|
||||||
|
|||||||
@@ -3,27 +3,39 @@
|
|||||||
## Servers
|
## Servers
|
||||||
|
|
||||||
Add multiple cluster servers (Settings). They auto-reconnect; one is the
|
Add multiple cluster servers (Settings). They auto-reconnect; one is the
|
||||||
**master** used for sending commands. The Cluster tab and the Main-view cluster
|
**master** used for sending commands. A quiet node is not treated as a dead one
|
||||||
pane share the same live feed.
|
— silence keeps its session, login and filters. The Cluster tab and the
|
||||||
|
Main-view cluster pane share the same live feed.
|
||||||
|
|
||||||
## Filter sidebar
|
## Filter sidebar
|
||||||
|
|
||||||
A show/hide **filter sidebar** (shared by the Cluster tab and the Main pane):
|
A show/hide **filter sidebar** (shared by the Cluster tab and the Main pane):
|
||||||
|
|
||||||
- **Callsign** search, **hide worked**, **group duplicates**.
|
- **Callsign** search, **hide worked**, **group duplicates**.
|
||||||
- Filter by **band / mode / status / source**.
|
- Filter by **band / mode / status / source**, plus **NEW POTA** and
|
||||||
|
**NEW COUNTY** chips.
|
||||||
|
|
||||||
## Per-spot status and tuning
|
## Per-spot status and tuning
|
||||||
|
|
||||||
Each spot is coloured by **status** relative to your log:
|
Each spot is coloured by **status** relative to your log:
|
||||||
|
|
||||||
- **new** (entity never worked), **new-band**, **new-slot**, **worked**.
|
- **new** (entity never worked), **new-band**, **new-mode**, **new-slot**,
|
||||||
|
**worked**.
|
||||||
|
- Settings → General → **Group digital modes as one (DXCC-style)** makes
|
||||||
|
FT8/FT4/RTTY/PSK count as a single Digital mode for the colouring and the
|
||||||
|
matrix badges, matching how DXCC counts.
|
||||||
|
|
||||||
Click a spot to **tune the rig** (fills the callsign into the entry strip). A
|
Click a spot to **tune the rig** (fills the callsign into the entry strip). A
|
||||||
multi-band **Band Map** shows panadapter-style strips per band.
|
multi-band **Band Map** shows panadapter-style strips per band — its CW / data
|
||||||
|
/ phone sub-bands use their own colour-blind-checked hues, and **Ctrl+↑ /
|
||||||
|
Ctrl+↓** jumps to the next spot above/below the current frequency and tunes it.
|
||||||
|
|
||||||
**POTA** spots are tagged with their park reference (via `api.pota.app`).
|
**POTA** spots are tagged with their park reference (via `api.pota.app`).
|
||||||
|
|
||||||
|
The entry strip's band matrix can also show the **ClubLog Most Wanted rank**
|
||||||
|
(Settings → General): an `MW #rank` pill next to the entity name, personalised
|
||||||
|
to your callsign, refreshed daily.
|
||||||
|
|
||||||
## Sending a spot
|
## Sending a spot
|
||||||
|
|
||||||
Use the **Send Spot** window to announce a DX spot on the master cluster:
|
Use the **Send Spot** window to announce a DX spot on the master cluster:
|
||||||
@@ -35,6 +47,12 @@ frequency** (full resolution, including sub-kHz).
|
|||||||
**Tools → Alert management** — Log4OM-style alert rules on **call / country /
|
**Tools → Alert management** — Log4OM-style alert rules on **call / country /
|
||||||
band / mode / spotter**, with **sound**, **visual** and **e-mail** notification.
|
band / mode / spotter**, with **sound**, **visual** and **e-mail** notification.
|
||||||
Active alerts appear as a discreet bell in the entry strip; click it for the
|
Active alerts appear as a discreet bell in the entry strip; click it for the
|
||||||
list. Alerts are kept briefly and only shown when active.
|
list.
|
||||||
|
|
||||||
|
## External spot sources
|
||||||
|
|
||||||
|
A **DXHunter** remote-call UDP packet fills the callsign, and when it carries
|
||||||
|
`<FREQ>` and `<MODE>` it also tunes whichever CAT backend is active. See
|
||||||
|
[[Settings and Data]] for the UDP configuration.
|
||||||
|
|
||||||
See also: [[Maps and Antennas]] for turning the beam to a spot.
|
See also: [[Maps and Antennas]] for turning the beam to a spot.
|
||||||
|
|||||||
+8
-6
@@ -1,10 +1,11 @@
|
|||||||
# OpsLog
|
# OpsLog
|
||||||
|
|
||||||
A modern, fast ham-radio logger for **Windows** — Log4OM-style entry, real-time
|
A modern, fast ham-radio logger for **Windows** — Log4OM-style entry, real-time
|
||||||
CAT for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB
|
CAT for **OmniRig**, native **FlexRadio/SmartSDR**, **Icom CI-V** (USB *and*
|
||||||
*and* remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert
|
remote-over-internet, replacing RS-BA1), **Yaesu**, **Kenwood**, **Xiegu** and
|
||||||
Electronics), a DX cluster with spot alerts, awards tracking, maps, contest
|
**TCI** (SunSDR / Expert Electronics) — with **CAT sharing** so WSJT-X & co use
|
||||||
logging, QSL management and a QSL-card designer.
|
the same rig link — a 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**
|
Built with **Wails v2** (Go backend + React/TypeScript frontend), **pure Go**
|
||||||
(no CGO). SQLite holds the configuration; the logbook can be the local SQLite
|
(no CGO). SQLite holds the configuration; the logbook can be the local SQLite
|
||||||
@@ -18,14 +19,15 @@ Developed by **F4BPO**.
|
|||||||
1. **[[Installation]]** — download / build and first launch.
|
1. **[[Installation]]** — download / build and first launch.
|
||||||
2. **[[Getting Started]]** — the entry strip, logging your first QSO, choosing a
|
2. **[[Getting Started]]** — the entry strip, logging your first QSO, choosing a
|
||||||
profile.
|
profile.
|
||||||
3. **[[CAT Control]]** — connect your radio (OmniRig, FlexRadio, Icom, TCI).
|
3. **[[CAT Control]]** — connect your radio (OmniRig, FlexRadio, Icom, Yaesu,
|
||||||
|
Kenwood, Xiegu, TCI).
|
||||||
|
|
||||||
## What OpsLog does
|
## What OpsLog does
|
||||||
|
|
||||||
| Area | Pages |
|
| Area | Pages |
|
||||||
|------|-------|
|
|------|-------|
|
||||||
| Enter and manage QSOs | [[Logging Basics]] · [[Recent QSOs and Filters]] · [[Import and Export ADIF]] |
|
| Enter and manage QSOs | [[Logging Basics]] · [[Recent QSOs and Filters]] · [[Import and Export ADIF]] |
|
||||||
| Control your radio | [[CAT Control]] · [[FlexRadio]] · [[Icom]] · [[Remote Icom over the Internet]] |
|
| Control your radio | [[CAT Control]] · [[FlexRadio]] · [[Icom]] · [[Remote Icom over the Internet]] · [[Yaesu]] · [[Kenwood and Xiegu]] |
|
||||||
| Chase DX | [[DX Cluster and Spots]] · [[Maps and Antennas]] |
|
| Chase DX | [[DX Cluster and Spots]] · [[Maps and Antennas]] |
|
||||||
| Station hardware | [[Amplifiers and Switches]] · [[Audio and Keyers]] |
|
| Station hardware | [[Amplifiers and Switches]] · [[Audio and Keyers]] |
|
||||||
| Events & contests | [[Contest Logging]] · [[Net Control]] · [[Multi-Operator Live Status]] |
|
| Events & contests | [[Contest Logging]] · [[Net Control]] · [[Multi-Operator Live Status]] |
|
||||||
|
|||||||
@@ -8,22 +8,34 @@ export modes.
|
|||||||
|
|
||||||
Import an `.adi` / `.adif` file into the active profile's logbook. On import,
|
Import an `.adi` / `.adif` file into the active profile's logbook. On import,
|
||||||
OpsLog parses every field, maps standard fields to columns and keeps unknown but
|
OpsLog parses every field, maps standard fields to columns and keeps unknown but
|
||||||
valid ADIF fields in a per-QSO **extras** store (editable later). Country / zones
|
valid ADIF fields in a per-QSO **extras** store (editable later). Country /
|
||||||
are enriched from `cty.dat` where missing.
|
zones are enriched from `cty.dat` where missing. A file **without a header**
|
||||||
|
(records pasted from another log) imports fine.
|
||||||
|
|
||||||
|
- **Field remapping**: the import dialog can move a field as it reads it —
|
||||||
|
e.g. the RSGB IOTA contest exports the island reference in `STATE`; add a
|
||||||
|
`STATE → IOTA` row and it lands where the award looks.
|
||||||
|
- **Fill from profile**: optionally stamps your station fields *and* your
|
||||||
|
default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog,
|
||||||
|
QRZ.com) on QSOs the file leaves empty — a WSJT-X log carries almost none.
|
||||||
|
Existing values are never overwritten.
|
||||||
|
|
||||||
## Export
|
## Export
|
||||||
|
|
||||||
- **Whole log**, **selected rows**, or the **filtered view** (no limit) — see
|
- **Whole log**, **selected rows**, or the **filtered view** (no limit) — see
|
||||||
[[Recent QSOs and Filters]].
|
[[Recent QSOs and Filters]].
|
||||||
- **Standard** mode exports the common promoted fields; **All** mode also exports
|
- **Standard** mode exports the common promoted fields; **All** mode also
|
||||||
every extra field.
|
exports every extra field; **Choose fields…** opens a picker that separates
|
||||||
|
the official ADIF 3.1.7 fields (by category) from the OpsLog / non-standard
|
||||||
|
tags actually present in your log, with All/None per group. Your selection is
|
||||||
|
remembered.
|
||||||
- **Cabrillo** export for contest submission.
|
- **Cabrillo** export for contest submission.
|
||||||
|
|
||||||
## Round-trip safety
|
## Round-trip safety
|
||||||
|
|
||||||
Award references you assign manually are stored in an ADIF extra field
|
Award references you assign manually are stored in an ADIF extra field
|
||||||
(`APP_OPSLOG_AWARDREFS`), so exporting your full log and re-importing it does not
|
(`APP_OPSLOG_AWARDREFS`), so exporting your full log and re-importing it does
|
||||||
lose your award-reference assignments.
|
not lose your award-reference assignments.
|
||||||
|
|
||||||
## Tips
|
## Tips
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Kenwood and Xiegu (native CAT)
|
||||||
|
|
||||||
|
## Kenwood (USB, network)
|
||||||
|
|
||||||
|
A native backend talks straight to a **TS-590 / TS-890 / TS-990 / TS-2000**
|
||||||
|
over its serial port — frequency, mode, VFO, split and PTT, no OmniRig in
|
||||||
|
between. **Elecraft K3/K4** speak the same dialect. Pick **Kenwood (USB,
|
||||||
|
network)** in Settings → CAT.
|
||||||
|
|
||||||
|
- **Serial**: COM port + baud.
|
||||||
|
- **Network**: give a `host:port` instead — for a serial bridge (ser2net, an
|
||||||
|
Ethernet-serial adapter). *Not* the radio's own RJ45, which speaks Kenwood's
|
||||||
|
KNS/ARCP protocol.
|
||||||
|
- Split is detected from FR/FT when the radio omits it from its status frame.
|
||||||
|
- The backend was tested end-to-end against a TS-2000 emulator and reports
|
||||||
|
useful errors: a port that opens but stays silent is distinguished from one
|
||||||
|
sending data that never answers (the error quotes what arrived).
|
||||||
|
|
||||||
|
## Xiegu (G90, X6100, X6200, X5105)
|
||||||
|
|
||||||
|
These radios speak CI-V but with a **reduced command set** (no scope, no DSP
|
||||||
|
block, no data mode), so they have their own backend — pick **Xiegu (USB)** in
|
||||||
|
Settings → CAT. Frequency, mode, split and PTT.
|
||||||
|
|
||||||
|
### PTT on a G90 — read this
|
||||||
|
|
||||||
|
A **G90 ignores the CI-V PTT command**. That has three consequences:
|
||||||
|
|
||||||
|
1. In the Xiegu settings, choose **how the rig is keyed**: CI-V command, RTS or
|
||||||
|
DTR — pick the hardware line your interface wires to PTT.
|
||||||
|
2. WSJT-X & co transmit through OpsLog's **shared CAT link** (Hamlib NET rigctl
|
||||||
|
`127.0.0.1:4532` — see [[CAT Control]]): OpsLog converts the PTT request
|
||||||
|
into the hardware line. This is the supported path and it works.
|
||||||
|
3. **WSJT-X + OmniRig will NOT transmit** a G90: OmniRig sends PTT as a CI-V
|
||||||
|
command, which the radio ignores. Either use the native Xiegu backend +
|
||||||
|
rigctl sharing, or configure the PTT line (RTS/DTR) inside OmniRig itself.
|
||||||
|
|
||||||
|
### Keying on connect
|
||||||
|
|
||||||
|
Behind an interface that carries PTT on a modem line (e.g. a DE-19), a G90 used
|
||||||
|
to go **straight into transmit when OpsLog connected** — Windows raises DTR and
|
||||||
|
RTS when a serial port opens. The Xiegu backend now drops both lines on
|
||||||
|
connect, then drives only the line you selected for keying.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Both backends refuse a CI-V/CAT frame read at the wrong offset, so a garbled
|
||||||
|
answer can no longer turn into a phantom frequency.
|
||||||
|
- The protocol trace (Settings → CAT) covers both — see [[CAT Control]].
|
||||||
+31
-8
@@ -10,8 +10,24 @@ save. Fields: callsign, RST tx/rx, name, QTH, grid, band, mode, TX/RX frequency
|
|||||||
CQ/ITU zones, continent) from `cty.dat` including `/MM` `/AM` `/B` and call-area
|
CQ/ITU zones, continent) from `cty.dat` including `/MM` `/AM` `/B` and call-area
|
||||||
handling (`/8`, `/W6`), plus **ClubLog** DXpedition date overrides.
|
handling (`/8`, `/W6`), plus **ClubLog** DXpedition date overrides.
|
||||||
- **Callsign lookup** (QRZ.com / HamQTH) fills name / QTH / grid and shows the
|
- **Callsign lookup** (QRZ.com / HamQTH) fills name / QTH / grid and shows the
|
||||||
photo and a QRZ.com tab. Configure it in Settings → Lookup.
|
photo and a QRZ.com tab. Configure it in Settings → Lookup. Portable calls
|
||||||
|
(`/M`, `/P`) are looked up on the home call too.
|
||||||
|
- **No lookup account?** The name / QTH / locator are recovered from the **last
|
||||||
|
time you worked the station**, and for US calls the offline **FCC (ULS)**
|
||||||
|
database fills state, county and a 6-character grid as you type. A real
|
||||||
|
QRZ/HamQTH hit still wins.
|
||||||
|
- A green **NEW** badge appears on the County field (Info / F2) when the US
|
||||||
|
county has never been worked.
|
||||||
- **ESC** clears the callsign (and stops CW).
|
- **ESC** clears the callsign (and stops CW).
|
||||||
|
- The frequency readout is **scroll-tunable**: roll the wheel over the
|
||||||
|
hundreds / tens / units-of-kHz digit and the rig follows.
|
||||||
|
|
||||||
|
## Super Check Partial / N+1
|
||||||
|
|
||||||
|
Enable in Settings → General (downloads the community MASTER.SCP list). As you
|
||||||
|
type a call, a docked two-column widget shows known calls that **contain** what
|
||||||
|
you typed (Partial) and calls **one character away** (N+1) — click one to fix a
|
||||||
|
busted call.
|
||||||
|
|
||||||
## Editing a QSO
|
## Editing a QSO
|
||||||
|
|
||||||
@@ -22,14 +38,18 @@ fields** editor for any ADIF field not promoted to a column.
|
|||||||
|
|
||||||
## Bulk operations
|
## Bulk operations
|
||||||
|
|
||||||
Select rows in Recent QSOs, right-click:
|
Select rows in Recent QSOs (a **Select all** button takes every displayed row),
|
||||||
|
right-click:
|
||||||
|
|
||||||
- **Fix country & zones** from `cty.dat`, or **update from QRZ / ClubLog**.
|
- **Fix country & zones** from `cty.dat`, or **update from the callsign
|
||||||
- **Bulk edit field** — set one field across many QSOs (QSL/upload status, my
|
databases** (QRZ / ClubLog), with a progress bar.
|
||||||
station, contest, propagation, contacted-station refs, comment…). Great for
|
- **Bulk edit field** — set one field across many QSOs: QSL/confirmation
|
||||||
flipping a batch to *Requested* before an upload.
|
statuses *and dates*, my station, contest, propagation, contacted-station
|
||||||
|
refs, comment, **gridsquare**, even the **frequency** (band recomputed,
|
||||||
|
out-of-band values refused).
|
||||||
- **Send to** a QSL service, **export** selected / filtered to ADIF or Cabrillo,
|
- **Send to** a QSL service, **export** selected / filtered to ADIF or Cabrillo,
|
||||||
**delete**.
|
**delete** (optionally withdrawing the QSO from QRZ.com and Club Log —
|
||||||
|
Settings → External services, off by default).
|
||||||
|
|
||||||
## Find duplicates
|
## Find duplicates
|
||||||
|
|
||||||
@@ -40,6 +60,9 @@ same day or same minute) and lets you pick which to delete.
|
|||||||
|
|
||||||
- **Worked-before matrix** shows prior contacts with the entity per band/mode
|
- **Worked-before matrix** shows prior contacts with the entity per band/mode
|
||||||
slot as you type.
|
slot as you type.
|
||||||
- Awards references are detected **live** as you enter a call (see [[Awards]]).
|
- Award references are detected **live** as you enter a call — including from
|
||||||
|
the QTH, name, comment or grid you typed (see [[Awards]]).
|
||||||
|
- The callsign in the top bar is the **station switcher**: click it to change
|
||||||
|
profile.
|
||||||
|
|
||||||
See also: [[Recent QSOs and Filters]] · [[Import and Export ADIF]]
|
See also: [[Recent QSOs and Filters]] · [[Import and Export ADIF]]
|
||||||
|
|||||||
+27
-15
@@ -3,36 +3,48 @@
|
|||||||
## Main view
|
## Main view
|
||||||
|
|
||||||
The Main view is **two configurable panes** (per profile, Settings → General →
|
The Main view is **two configurable panes** (per profile, Settings → General →
|
||||||
*Main view*). Choose from: great-circle map, locator (street) map, the cluster
|
*Main view*), with a **draggable divider** between them (double-click it to
|
||||||
grid, the worked-before grid, recent QSOs, the **FlexRadio** controls, the
|
even them out). Choose from: great-circle map, locator (street) map, the
|
||||||
**Icom** console, or the **Net control** panel.
|
cluster grid, the worked-before grid, recent QSOs, the **FlexRadio** controls,
|
||||||
|
the **Icom** or **Yaesu** console, or the **Net control** panel.
|
||||||
|
|
||||||
## Great-circle map
|
## Great-circle map
|
||||||
|
|
||||||
- Short/long-path **distance & azimuth** to the worked station.
|
- Short/long-path **distance & azimuth** to the worked station.
|
||||||
- Selectable, **key-free** basemaps: Light / Voyager / Street / Satellite (all
|
- Selectable, **key-free** basemaps: Light / Voyager / Street / Satellite.
|
||||||
labelled).
|
|
||||||
- The **antenna beam lobe(s)** are drawn from the rotor azimuth.
|
- The **antenna beam lobe(s)** are drawn from the rotor azimuth.
|
||||||
|
- **Day/night terminator** and twilight band (button at the top right).
|
||||||
|
|
||||||
## Locator (street) map
|
## Locator (street) map
|
||||||
|
|
||||||
An OSM street map centred on the contacted grid square — handy for local /
|
An OSM street map centred on the contacted grid square — handy for local /
|
||||||
portable contacts. No API key.
|
portable contacts. No API key.
|
||||||
|
|
||||||
## Rotor compass
|
## Rotators
|
||||||
|
|
||||||
An azimuthal-equidistant **click-to-turn** compass driven by **PstRotator**. The
|
An azimuthal-equidistant **click-to-turn** compass; the current heading and
|
||||||
current heading and target are shown; click a bearing to turn the rotor.
|
target are shown. Three rotator types (Settings → Rotator):
|
||||||
|
|
||||||
## Ultrabeam
|
- **PstRotator** — via its UDP interface.
|
||||||
|
- **microHAM ARCO** — native, over LAN or USB: set the ARCO's CONTROL PROTOCOL
|
||||||
|
to *Yaesu GS-232A*.
|
||||||
|
- **GS-232A (generic)** — any GS-232A controller, ERC (Easy Rotor Control)
|
||||||
|
included; serial speed selectable. Set an ERC to GS-232 emulation, not
|
||||||
|
Hy-Gain DCU-1.
|
||||||
|
|
||||||
Ultrabeam antennas are supported with three modes — **Normal**, **180° reverse**,
|
## Motorized antennas (Ultrabeam / SteppIR)
|
||||||
|
|
||||||
|
Both follow the **rig frequency** (band change → retune) and retune immediately
|
||||||
|
when you click a spot, with three modes — **Normal**, **180° reverse**,
|
||||||
**Bidirectional**:
|
**Bidirectional**:
|
||||||
|
|
||||||
- The **radiating direction** is shown in green and the **mechanical boom** in
|
- The **radiating direction** is shown in green and the **mechanical boom** in
|
||||||
grey, on both the compass and the map, so you always know where the antenna
|
grey, on both the compass and the map.
|
||||||
actually points.
|
- On a FlexRadio, transmit is **inhibited while the elements move**, and
|
||||||
- The antenna **follows the rig frequency** (band change → retune), and retunes
|
released as soon as the antenna is tuned.
|
||||||
immediately when you click a spot.
|
- **SteppIR — Tunable range** (Settings → Hardware → Antenna, default
|
||||||
|
13–54 MHz): outside it OpsLog leaves the antenna and the TX interlock
|
||||||
|
completely alone, so tuning to 30 m on a 20 m–6 m SteppIR does nothing.
|
||||||
|
|
||||||
Related hardware: [[Amplifiers and Switches]] (Antenna Genius switch).
|
Related hardware: [[Amplifiers and Switches]] (Antenna Genius switch, Tuner
|
||||||
|
Genius, relays).
|
||||||
|
|||||||
@@ -6,18 +6,29 @@ Every setting in OpsLog is **per-profile**: station identity, CAT, lookup, QSL
|
|||||||
defaults, awards display, antennas, and which logbook to use. Create one profile
|
defaults, awards display, antennas, and which logbook to use. Create one profile
|
||||||
per callsign / station / event.
|
per callsign / station / event.
|
||||||
|
|
||||||
Switch or manage profiles in Settings. The active profile decides which logbook
|
The **callsign in the top bar is the station switcher** — click it and pick a
|
||||||
your QSOs go to.
|
profile. Managing profiles is at the bottom of that list (and in Settings).
|
||||||
|
|
||||||
## Config vs. logbook — two different stores
|
## Two separate databases
|
||||||
|
|
||||||
|
Since 0.21.0 the settings and the QSOs live in **separate files**:
|
||||||
|
|
||||||
| Store | What | Where |
|
| Store | What | Where |
|
||||||
|-------|------|-------|
|
|-------|------|-------|
|
||||||
| **Config** | settings, profiles, rigs/antennas, cluster nodes, lookup cache, award lists, QSL templates | always the **local SQLite** file under `data/` |
|
| **Settings database** (`settings.db`, or `opslog.db` on older installs) | settings, profiles, rigs/antennas, cluster nodes, lookup cache, award lists, QSL templates | always the **local SQLite** file under `data/` |
|
||||||
| **Logbook** | your QSOs | where the active profile points — local SQLite **or** shared MySQL |
|
| **Logbook** (`logbook.db`) | your QSOs | where the active profile points — a local SQLite file **or** shared MySQL |
|
||||||
|
|
||||||
Keeping config local means the UI is instant even when the logbook is a far-away
|
Keeping the settings local means the UI is instant even when the logbook is a
|
||||||
MySQL server.
|
far-away MySQL server. Settings → Database shows the two as separate sections,
|
||||||
|
with an *Open folder* shortcut; a logbook file can be **renamed or relocated**
|
||||||
|
(its QSOs move with it), and the status bar names the **logbook** file — that
|
||||||
|
is the one to back up.
|
||||||
|
|
||||||
|
A profile can point at its **own logbook file** — ideal for a visiting
|
||||||
|
operator, whose contacts stay out of your log without touching your settings.
|
||||||
|
|
||||||
|
**Portable**: paths inside the OpsLog folder are stored relative to it, so
|
||||||
|
moving the whole folder to another drive or PC loses nothing.
|
||||||
|
|
||||||
## Shared MySQL logbook (multi-operator)
|
## Shared MySQL logbook (multi-operator)
|
||||||
|
|
||||||
@@ -25,11 +36,18 @@ Point a profile's logbook at a **MySQL** database so several operators run **one
|
|||||||
log** — e.g. a multi-op special-event call. Configure the host / database /
|
log** — e.g. a multi-op special-event call. Configure the host / database /
|
||||||
credentials in the profile's database settings.
|
credentials in the profile's database settings.
|
||||||
|
|
||||||
This is also what powers [[Multi-Operator Live Status]] (each instance heartbeats
|
- The database and tables are converted to **utf8mb4** on connect, so Cyrillic
|
||||||
its activity into a `live_status` table).
|
or Polish characters from a QRZ lookup store correctly even when the hosting
|
||||||
|
panel created the database as latin1.
|
||||||
|
- An FT8/MSHV QSO that cannot reach a laggy shared database is **parked and
|
||||||
|
re-logged automatically** once it recovers — no lost QSOs.
|
||||||
|
- Switching to a profile whose MySQL is unreachable shows a clear warning
|
||||||
|
instead of silently staying on the previous logbook.
|
||||||
|
|
||||||
|
This is also what powers [[Multi-Operator Live Status]].
|
||||||
|
|
||||||
## Backups
|
## Backups
|
||||||
|
|
||||||
Optional **database + ADIF backup at shutdown** (Settings → Backup). Regardless,
|
Optional **database + ADIF backup at shutdown** (Settings → Backup). Regardless,
|
||||||
copying the `data/` folder backs up your config and local logbook. See
|
copying the `data/` folder backs up your settings and local logbook. See
|
||||||
[[Settings and Data]].
|
[[Settings and Data]].
|
||||||
|
|||||||
@@ -4,13 +4,23 @@
|
|||||||
|
|
||||||
The main log table. Double-click a row to edit; right-click for bulk actions
|
The main log table. Double-click a row to edit; right-click for bulk actions
|
||||||
(see [[Logging Basics]]). Columns are configurable — click **Columns** to choose
|
(see [[Logging Basics]]). Columns are configurable — click **Columns** to choose
|
||||||
which are visible; your selection is saved (per profile).
|
which are visible; widths, order and hidden columns are saved (per profile),
|
||||||
|
with a separate layout for the narrower Main-tab pane.
|
||||||
|
|
||||||
|
- The selected-row count is always visible at the top, and a **Select all /
|
||||||
|
Unselect all** button takes every displayed row.
|
||||||
|
- QSL and upload status columns are coloured: **Y** green, **N** red, **R**
|
||||||
|
blue.
|
||||||
|
- A **Distance (km)** column (also in Worked-before) is computed from the QSO's
|
||||||
|
own locator pair.
|
||||||
|
- There are **no per-column header filters** — they only ever searched the rows
|
||||||
|
on screen, which made a QSO further back in the log look missing. Use the
|
||||||
|
advanced filter, which queries the whole logbook.
|
||||||
|
|
||||||
### Award columns
|
### Award columns
|
||||||
|
|
||||||
Each defined award can show a column with the reference the QSO counts for. These
|
Each defined award can show a column with the reference the QSO counts for.
|
||||||
columns are **hidden by default** and opt-in from the Columns picker — turn on
|
These columns are **hidden by default** and opt-in from the Columns picker.
|
||||||
only the awards you're chasing. Your choice persists across restarts.
|
|
||||||
|
|
||||||
## Advanced filter builder
|
## Advanced filter builder
|
||||||
|
|
||||||
@@ -19,15 +29,19 @@ Click the filter button to open the **QSO filter builder** (Log4OM-style):
|
|||||||
- Add one or more **conditions**: *field · operator · value*.
|
- Add one or more **conditions**: *field · operator · value*.
|
||||||
- Operators: equals, not-equal, contains, starts/ends with, greater/less
|
- Operators: equals, not-equal, contains, starts/ends with, greater/less
|
||||||
(or equal), is empty, is not empty.
|
(or equal), is empty, is not empty.
|
||||||
|
- Fields include every confirmation **status and date** (sent / received,
|
||||||
|
before / after) for paper QSL, LoTW, eQSL, Club Log, HRDLog and QRZ.com.
|
||||||
|
Field names stay in English — they are ADIF names, a standard vocabulary.
|
||||||
- Join conditions with **ALL (AND)** or **ANY (OR)**.
|
- Join conditions with **ALL (AND)** or **ANY (OR)**.
|
||||||
- Save named **presets** (they travel with the `data/` folder).
|
- Save named **presets** (they travel with the `data/` folder).
|
||||||
- The field dropdown is sorted alphabetically to find things fast.
|
|
||||||
|
|
||||||
The filtered view can be exported: **selected rows** or the **whole filtered
|
A filter shows **every** match (the on-screen row limit only applies to the
|
||||||
view** (no row limit) → ADIF or Cabrillo.
|
unfiltered log; safety cap 10 000). The filtered view can be exported:
|
||||||
|
**selected rows** or the **whole filtered view** → ADIF or Cabrillo.
|
||||||
|
|
||||||
## Worked-before matrix
|
## Worked-before matrix
|
||||||
|
|
||||||
As you type a callsign, the worked-before grid shows prior contacts with that
|
As you type a callsign, the worked-before grid shows prior contacts with that
|
||||||
**entity**, arranged by band/mode slot, so you instantly see what's new (new
|
**entity**, arranged by band/mode slot, so you instantly see what's new (new
|
||||||
DXCC / new band / new slot) versus already worked.
|
DXCC / new band / new slot) versus already worked. Its right-click menu offers
|
||||||
|
the same bulk edit and export as the main grid.
|
||||||
|
|||||||
+32
-22
@@ -4,48 +4,58 @@
|
|||||||
|
|
||||||
A `data/` folder is created **next to `OpsLog.exe`**:
|
A `data/` folder is created **next to `OpsLog.exe`**:
|
||||||
|
|
||||||
- **Config** (SQLite): settings, profiles, rigs/antennas, cluster nodes, lookup
|
- **Settings database** (SQLite): settings, profiles, rigs/antennas, cluster
|
||||||
cache, award lists, QSL templates. Always local, so the UI is instant.
|
nodes, lookup cache, award lists, QSL templates. Always local.
|
||||||
- **Local logbook** (SQLite): QSOs, when the profile logs locally.
|
- **Logbook** (`logbook.db`, SQLite): QSOs, when the profile logs locally.
|
||||||
- `data/qsl/templates/…` — QSL card templates and their photos.
|
- `data/qsl/templates/…` — QSL card templates and their photos.
|
||||||
- `data/qsl/outbox/…` — sent QSL card JPEGs.
|
- `data/qsl/outbox/…` — sent QSL card JPEGs.
|
||||||
- Recordings folder (configurable) — per-QSO WAV/MP3.
|
- Recordings folder (configurable) — per-QSO WAV/MP3.
|
||||||
- Backups.
|
- Backups, diagnostic logs.
|
||||||
|
|
||||||
Back up your setup by copying `data/` (and keep it with the exe). See
|
Paths are stored **relative to the folder**, so the whole OpsLog directory can
|
||||||
|
move to another drive or PC. Back up your setup by copying `data/`. See
|
||||||
[[Profiles and Databases]] for local-vs-MySQL logbooks.
|
[[Profiles and Databases]] for local-vs-MySQL logbooks.
|
||||||
|
|
||||||
## Settings map
|
## Settings map
|
||||||
|
|
||||||
| Section | What |
|
| Section | What |
|
||||||
|---------|------|
|
|---------|------|
|
||||||
| **General** | language, theme, Main-view panes, live-status publish |
|
| **General** | language, theme, date format (US / FR / Standard), Main-view panes, digital-modes grouping, Super Check Partial, ClubLog exceptions & Most Wanted |
|
||||||
| **Station** | your callsign, grid, name, address (my-station ADIF) |
|
| **Station** | your callsign, grid, name, address (my-station ADIF) — hand-corrected CQ/ITU zones stay corrected |
|
||||||
| **CAT** | radio backend + connection ([[CAT Control]]) |
|
| **CAT** | radio backend + connection, CAT sharing (rigctld), protocol trace ([[CAT Control]]) |
|
||||||
| **Lookup** | QRZ.com / HamQTH credentials |
|
| **Lookup** | QRZ.com / HamQTH credentials |
|
||||||
| **FlexRadio** | per-band RX/TX antennas |
|
| **Audio** | DVK / recorder devices, PTT, gains and levels, RX monitor |
|
||||||
| **Audio** | DVK / recorder devices, PTT, gains, RX monitor |
|
| **CW Keyer** | engine (WinKeyer / Serial / Icom / Flex CWX / Yaesu / TCI), macros, ESM |
|
||||||
| **CW Keyer** | WinKeyer / Icom / TCI engine + macros |
|
| **External services** | LoTW (TQSL), QRZ, eQSL, ClubLog, HRDLog, POTA, Cloudlog/Wavelog |
|
||||||
| **External services** | LoTW (TQSL), QRZ, eQSL, ClubLog, HRDLog, POTA |
|
| **Email** | SMTP for eQSL-by-email and *Send log to F4BPO* |
|
||||||
| **Email** | SMTP for eQSL-by-email |
|
| **Amplifier / Tuner Genius / Antenna Genius / Rotator / Antenna** | station hardware ([[Amplifiers and Switches]] · [[Maps and Antennas]]) |
|
||||||
| **PowerGenius / Antenna Genius / Rotator / Ultrabeam** | station hardware |
|
| **Database** | settings DB + this profile's logbook, rename/relocate |
|
||||||
| **Backup** | database + ADIF backup at shutdown |
|
| **Backup** | database + ADIF backup at shutdown |
|
||||||
|
|
||||||
## Appearance & language
|
## Appearance & language
|
||||||
|
|
||||||
Four themes (Warm light, Warm dark, Graphite dark, High contrast) plus **Auto**
|
Four themes (Warm light, Warm dark, Graphite dark, High contrast) plus **Auto**
|
||||||
(follows the OS). Full **English / French** UI with a first-run flag chooser and
|
(follows the OS); fresh installs start dark. Full **English / French** UI with
|
||||||
a switcher in Settings → General.
|
a first-run flag chooser and a switcher in Settings → General. **Ctrl + wheel**
|
||||||
|
zooms the whole UI (remembered; Ctrl+0 resets). The window title bar is drawn
|
||||||
|
by OpsLog — drag the top bar to move, double-click to maximise — and the window
|
||||||
|
reopens on the screen it was closed on.
|
||||||
|
|
||||||
## Integrations (outbound UDP)
|
## UDP integrations
|
||||||
|
|
||||||
Push the current frequency to **PstRotator**, radio info in **N1MM `RadioInfo`**
|
- **Inbound**: WSJT-X / MSHV / JTDX QSO logging and status (unicast or
|
||||||
format, or an **ADIF record on each logged QSO** — so external tools stay in sync.
|
multicast), and DXHunter remote-call packets (fills the callsign, tunes the
|
||||||
|
rig when the packet carries `<FREQ>`/`<MODE>`).
|
||||||
|
- **Outbound**: the current frequency to **PstRotator**, radio info in **N1MM
|
||||||
|
`RadioInfo`** format, or an **ADIF record on each logged QSO**.
|
||||||
|
|
||||||
## Other
|
## Other
|
||||||
|
|
||||||
- **Autostart** external programs (WSJT-X, JTAlert, rotator control…) at launch,
|
- **Autostart** external programs (WSJT-X, JTAlert, rotator control…) at
|
||||||
skipping any already running.
|
launch, skipping any already running.
|
||||||
- **Update check** at startup (toggleable).
|
- **Update check** at startup, when opening Help → About, and every 5 minutes.
|
||||||
|
A "What's new" summary appears on the first launch after each update.
|
||||||
|
- **Help → Send log to F4BPO** (shown when SMTP is configured) e-mails the
|
||||||
|
diagnostic logs straight to the developer.
|
||||||
- **Anonymous usage telemetry** — a once-a-day heartbeat (random install ID +
|
- **Anonymous usage telemetry** — a once-a-day heartbeat (random install ID +
|
||||||
version + OS; no callsign or QSO data). Opt out in Preferences.
|
version + OS; no callsign or QSO data). Opt out in Preferences.
|
||||||
|
|||||||
+50
-30
@@ -2,57 +2,77 @@
|
|||||||
|
|
||||||
## Where's the log file?
|
## Where's the log file?
|
||||||
|
|
||||||
OpsLog writes a diagnostic log. The path is shown in Settings (and can be opened
|
OpsLog writes a diagnostic log. The path is shown in Settings (and can be
|
||||||
from there). When reporting a problem, include the relevant lines — they're
|
opened from there). When reporting a problem, include the relevant lines —
|
||||||
prefixed by subsystem (`Flex:`, `icom net:`, `icom scope`, `antgenius:`,
|
they're prefixed by subsystem (`cat:`, `Flex:`, `icom net:`, `antgenius:`,
|
||||||
`cluster:`, `qslmgr:log`, `audio:`…).
|
`cluster:`, `qslmgr:log`, `audio:`…). With an SMTP server configured, **Help →
|
||||||
|
Send log to F4BPO** e-mails the logs straight to the developer.
|
||||||
|
|
||||||
|
A crash no longer leaves a blank window: the error is shown on screen,
|
||||||
|
selectable, with a copy button.
|
||||||
|
|
||||||
## CAT
|
## CAT
|
||||||
|
|
||||||
- **Radio not connecting** — check the backend fields (COM port + baud for USB;
|
- **Radio not connecting** — check the backend fields (COM port + baud for USB;
|
||||||
IP/credentials for network). OpsLog connects non-blocking, so it won't freeze —
|
IP/credentials for network). The CAT log line carries the error explaining a
|
||||||
watch the CAT status.
|
disconnection.
|
||||||
- **Icom: no CAT at all over USB** — the CI-V address must match the radio; pick
|
- **"Serial port busy" in the log** — another program holds the COM port
|
||||||
your **model** from the dropdown (it sets the address) and set *CI-V USB Echo
|
(WSJT-X, MSHV, another logger configured for direct CAT). Only one program
|
||||||
Back* **OFF** on the rig.
|
can own the port: let OpsLog hold it and point the others at **Hamlib NET
|
||||||
- **Icom scope blank on an IC-7300** — this was a bug where the scope decoder was
|
rigctl 127.0.0.1:4532** (Settings → CAT → Share CAT). Two masters on one
|
||||||
chosen from the configured CI-V address; it now uses the model detected from the
|
CI-V bus also cause garbled frames and erratic behaviour.
|
||||||
CI-V ID, so a single-scope rig run at address `0x98` decodes correctly. Update
|
- **The radio transmits as soon as OpsLog connects** — your interface reads DTR
|
||||||
to the latest build.
|
or RTS as PTT. Xiegu drops the lines automatically; on Yaesu and Kenwood
|
||||||
- **Icom attenuator button does nothing** — the dB steps are model-specific; make
|
enable the *lower DTR/RTS on connect* option. (It is off by default because
|
||||||
sure the right model is selected (IC-7300 = 20 dB, IC-7610 = 6/12/18 dB).
|
other interfaces stop transmitting with the lines low.)
|
||||||
|
- **WSJT-X won't key a Xiegu G90 through OmniRig** — a G90 ignores the CI-V PTT
|
||||||
|
command. Use the native Xiegu backend (which keys RTS/DTR) + rigctl sharing,
|
||||||
|
or set the PTT line inside OmniRig. See [[Kenwood and Xiegu]].
|
||||||
|
- **OmniRig frequency frozen / wrong VFO** — set **VFO to read** (A or B) in
|
||||||
|
the OmniRig backend settings; some rig files declare the wrong VFO.
|
||||||
|
- **"OmniRig not found" with OmniRig running** — one of the two runs as
|
||||||
|
administrator and the other doesn't. Start both the same way.
|
||||||
|
- **Icom: no CAT at all over USB** — the CI-V address must match the radio;
|
||||||
|
pick your **model** from the dropdown (it sets the address) and set *CI-V USB
|
||||||
|
Echo Back* **OFF** on the rig.
|
||||||
|
- **A radio that answers oddly** — enable the **protocol trace** (Settings →
|
||||||
|
CAT): every frame to and from the rig lands in the log as hex, which turns a
|
||||||
|
guess into a diagnosis.
|
||||||
|
|
||||||
## Remote Icom
|
## Remote Icom
|
||||||
|
|
||||||
- **Login rejected** → wrong Network User1 name/password.
|
- **Login rejected** → wrong Network User1 name/password.
|
||||||
- **Can't bind local port** → the Icom **Remote Utility is still running** —
|
- **Can't bind local port** → the Icom **Remote Utility is still running** —
|
||||||
close it.
|
close it.
|
||||||
- **Link drops with the scope on** → receive-side retransmit should hold it; if
|
- **Frequency frozen after another program took the CI-V session** — OpsLog
|
||||||
not, check network quality and the `icom net:` log lines. See
|
reconnects on its own; if it doesn't, check the `icom net:` log lines. See
|
||||||
[[Remote Icom over the Internet]].
|
[[Remote Icom over the Internet]].
|
||||||
|
|
||||||
## FlexRadio
|
## FlexRadio
|
||||||
|
|
||||||
- **A control reads 0 / stale at startup** — some SmartSDR status fields use short
|
- **A control reads 0 / stale at startup** — some SmartSDR status fields use
|
||||||
names (e.g. the TX filter is reported as `lo`/`hi`, not `filter_low/high`). If a
|
short names. If a value never populates, capture the `Flex: … status` log
|
||||||
value never populates, capture the `Flex: … status` log line and report it.
|
line and report it.
|
||||||
- **Antenna wrong band on a spot click** — fixed: `SetFrequency`/`SetMode` update
|
- **SmartSDR CAT keeps disconnecting while OpsLog is open** — fixed: OpsLog now
|
||||||
the slice cache immediately so the panel and the Ultrabeam follow don't chase a
|
binds only to the actual SmartSDR/Maestro GUI client. Update.
|
||||||
stale frequency.
|
- **"Interlock is preventing transmission" with a SteppIR** — see the tunable
|
||||||
|
range setting in [[Maps and Antennas]].
|
||||||
|
|
||||||
## Multi-op live status PHP
|
## Multi-op live status PHP
|
||||||
|
|
||||||
`Connection refused` from the PHP page is almost always MySQL `bind-address =
|
`Connection refused` from the PHP page is almost always MySQL `bind-address =
|
||||||
127.0.0.1` or a firewall/grant issue — see
|
127.0.0.1` or a firewall/grant issue — see [[Multi-Operator Live Status]].
|
||||||
[[Multi-Operator Live Status]].
|
|
||||||
|
|
||||||
## DVK / PTT
|
## DVK / PTT
|
||||||
|
|
||||||
- **Test PTT does nothing on CAT/OmniRig** — the key is held ~1.5 s so the async
|
- **Test PTT does nothing on CAT/OmniRig** — the key is held ~1.5 s so the
|
||||||
OmniRig write is actually sent; if it still doesn't key, the rig's OmniRig
|
async OmniRig write is actually sent; if it still doesn't key, the rig's
|
||||||
profile may not expose CAT TX keying — use RTS/DTR or VOX.
|
OmniRig profile may not expose CAT TX keying — use RTS/DTR or VOX.
|
||||||
|
- **A voice message transmits almost nothing** — raise the message level
|
||||||
|
(Settings → Audio) and check the rig's rear-input setting (FTDX10: SSB MOD
|
||||||
|
SOURCE = REAR).
|
||||||
|
|
||||||
## Still stuck?
|
## Still stuck?
|
||||||
|
|
||||||
Open an issue with: what you did, what happened, your radio/model, and the
|
Open an issue (or *Send log to F4BPO*) with: what you did, what happened, your
|
||||||
relevant log lines.
|
radio/model, and the relevant log lines.
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Yaesu (native CAT)
|
||||||
|
|
||||||
|
A native backend talks to an **FTDX10 / FTDX101** (and close relatives) directly
|
||||||
|
over the rig's USB/serial port — no OmniRig. Pick **Yaesu (USB)** in
|
||||||
|
Settings → CAT: COM port + baud (FTDX10/101 default 38400).
|
||||||
|
|
||||||
|
Frequency, mode, VFO A/B (MAIN/SUB on the 101), split and PTT are covered, and
|
||||||
|
OpsLog **follows the VFO you are actually on** — the displayed frequency, the
|
||||||
|
logged frequency and the VFO a cluster spot tunes. Split is worked out from
|
||||||
|
which VFO transmits, so simplex on the SUB receiver is not shown as split.
|
||||||
|
|
||||||
|
## The Yaesu console
|
||||||
|
|
||||||
|
With the native backend connected, a console tab (and a Main-view pane) gives:
|
||||||
|
|
||||||
|
- **S / PO / SWR meters** — power in watts on a curve measured against the rig's
|
||||||
|
own display, SWR as the ratio itself, with peak-hold so they stay readable
|
||||||
|
between CW words or SSB syllables.
|
||||||
|
- **Band and mode buttons** — CW, RTTY, DIGI and PSK show their sideband; click
|
||||||
|
the active button again to switch it.
|
||||||
|
- AF / RF / squelch, AGC, IPO–AMP1–AMP2, 6/12/18 dB attenuator, NB, DNR, narrow
|
||||||
|
filter, power, mic gain, VOX, split and **ATU tune**.
|
||||||
|
- **SPLIT** places the transmit VFO up 1 kHz on CW/data, up 5 kHz on phone.
|
||||||
|
- In CW the mic gain and VOX give way to a **CW card**: keyer speed, break-in
|
||||||
|
and ZIN (zero-in).
|
||||||
|
|
||||||
|
Sliders only respond to the mouse wheel after you click them, so scrolling the
|
||||||
|
panel can't change the transmit power by accident.
|
||||||
|
|
||||||
|
## CW with the rig's own keyer
|
||||||
|
|
||||||
|
A CW engine sends your macros with the radio's internal keyer (CAT `KY`) — no
|
||||||
|
WinKeyer, no second cable. Pick **Yaesu (rig keyer)** in Settings → CW keyer.
|
||||||
|
|
||||||
|
- Works on the **FTDX101 / FT-991A / FT-710** family.
|
||||||
|
- An **FTDX10 does not accept it** (`KY` is refused, `DAKY` doesn't help) —
|
||||||
|
OpsLog says so and points to the **serial DTR keyer** on the rig's second COM
|
||||||
|
port (the *Serial port* CW engine), which works. See [[Audio and Keyers]].
|
||||||
|
- CW speed is one setting everywhere: the console slider, the CW panel and the
|
||||||
|
rig's front panel stay in agreement.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- A Yaesu that is switched off is reported as such (opening the port is not
|
||||||
|
"connected").
|
||||||
|
- If your interface goes into transmit the moment OpsLog connects, enable the
|
||||||
|
**lower DTR/RTS on connect** option — see [[CAT Control]].
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
- [[FlexRadio]]
|
- [[FlexRadio]]
|
||||||
- [[Icom]]
|
- [[Icom]]
|
||||||
- [[Remote Icom over the Internet]]
|
- [[Remote Icom over the Internet]]
|
||||||
|
- [[Yaesu]]
|
||||||
|
- [[Kenwood and Xiegu]]
|
||||||
|
|
||||||
**Operating**
|
**Operating**
|
||||||
- [[DX Cluster and Spots]]
|
- [[DX Cluster and Spots]]
|
||||||
|
|||||||
Reference in New Issue
Block a user